diff --git a/README.md b/README.md index 76d10ba8..aaf3dd09 100644 --- a/README.md +++ b/README.md @@ -86,15 +86,15 @@ git-stacked-rebase 2. but will not apply the changes to partial branches until --apply is used. -git-stacked-rebase [-a|--apply] +git-stacked-rebase [-a|--apply] - 1. will apply the changes to partial branches, - 2. but will not push any partial branches to a remote until --push is used. + 3. will apply the changes to partial branches, + 4. but will not push any partial branches to a remote until --push is used. -git-stacked-rebase [-p|--push -f|--force] +git-stacked-rebase [-p|--push -f|--force] - 1. will push partial branches with --force (and extra safety). + 5. will push partial branches with --force (and extra safety). diff --git a/argparse/argparse.spec.ts b/argparse/argparse.spec.ts new file mode 100755 index 00000000..5bbe2bed --- /dev/null +++ b/argparse/argparse.spec.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env ts-node-dev + +import assert from "assert"; + +import { NonPositional, NonPositionalWithValue, eatNonPositionals, eatNonPositionalsWithValues } from "./argparse"; + +export function argparse_TC() { + eatNonPositionals_singleArg(); + eatNonPositionals_multipleArgs(); + + eatNonPositionalsWithValues_singleArg(); + eatNonPositionalsWithValues_multipleArgs(); +} + +function eatNonPositionals_singleArg() { + const argv = ["origin/master", "--autosquash", "foo", "bar"]; + const targetArgs = ["--autosquash", "--no-autosquash"]; + const expected: NonPositional[] = [{ argName: "--autosquash", origIdx: 1 }]; + const expectedLeftoverArgv = ["origin/master", "foo", "bar"]; + + const parsed = eatNonPositionals(targetArgs, argv); + + assert.deepStrictEqual(parsed, expected); + assert.deepStrictEqual(argv, expectedLeftoverArgv); +} +function eatNonPositionals_multipleArgs() { + const argv = ["origin/master", "--autosquash", "foo", "bar", "--no-autosquash", "baz"]; + const targetArgs = ["--autosquash", "--no-autosquash"]; + const expected: NonPositional[] = [ + { argName: "--autosquash", origIdx: 1 }, + { argName: "--no-autosquash", origIdx: 4 }, + ]; + const expectedLeftoverArgv = ["origin/master", "foo", "bar", "baz"]; + + const parsed = eatNonPositionals(targetArgs, argv); + + assert.deepStrictEqual(parsed, expected); + assert.deepStrictEqual(argv, expectedLeftoverArgv); +} + +function eatNonPositionalsWithValues_singleArg() { + const argv = ["origin/master", "--git-dir", "~/.dotfiles", "foo", "bar"]; + const targetArgs = ["--git-dir", "--gd"]; + const expected: NonPositionalWithValue[] = [{ argName: "--git-dir", origIdx: 1, argVal: "~/.dotfiles" }]; + const expectedLeftoverArgv = ["origin/master", "foo", "bar"]; + + const parsed = eatNonPositionalsWithValues(targetArgs, argv); + + assert.deepStrictEqual(parsed, expected); + assert.deepStrictEqual(argv, expectedLeftoverArgv); +} +function eatNonPositionalsWithValues_multipleArgs() { + const argv = ["origin/master", "--git-dir", "~/.dotfiles", "foo", "bar", "--misc", "miscVal", "unrelatedVal"]; + const targetArgs = ["--git-dir", "--gd", "--misc"]; + const expected: NonPositionalWithValue[] = [ + { argName: "--git-dir", origIdx: 1, argVal: "~/.dotfiles" }, + { argName: "--misc", origIdx: 5, argVal: "miscVal" }, + ]; + const expectedLeftoverArgv = ["origin/master", "foo", "bar", "unrelatedVal"]; + + const parsed = eatNonPositionalsWithValues(targetArgs, argv); + + assert.deepStrictEqual(parsed, expected); + assert.deepStrictEqual(argv, expectedLeftoverArgv); +} + +if (!module.parent) { + argparse_TC(); +} diff --git a/argparse/argparse.ts b/argparse/argparse.ts new file mode 100644 index 00000000..55675d3e --- /dev/null +++ b/argparse/argparse.ts @@ -0,0 +1,140 @@ +import assert from "assert"; + +export type Maybe = T | undefined; + +export type Argv = string[]; +export type MaybeArg = Maybe; + +/** + * parses the argv. + * mutates the `argv` array. + */ +export function createArgParse(argv: Argv) { + const getArgv = (): Argv => argv; + const peakNextArg = (): MaybeArg => argv[0]; + const eatNextArg = (): MaybeArg => argv.shift(); + const hasMoreArgs = (): boolean => argv.length > 0; + + return { + getArgv, + peakNextArg, + eatNextArg, + hasMoreArgs, + eatNonPositionals: (argNames: string[]) => eatNonPositionals(argNames, argv), + eatNonPositionalsWithValues: (argNames: string[]) => eatNonPositionalsWithValues(argNames, argv), + }; +} + +export type NonPositional = { + origIdx: number; + argName: string; +}; + +export type NonPositionalWithValue = NonPositional & { + argVal: string; +}; + +export function eatNonPositionals( + argNames: string[], + argv: Argv, + { + howManyItemsToTakeWhenArgMatches = 1, // + } = {} +): NonPositional[] { + const argMatches = (idx: number) => argNames.includes(argv[idx]); + let matchedArgIndexes: NonPositional["origIdx"][] = []; + + for (let i = 0; i < argv.length; i++) { + if (argMatches(i)) { + for (let j = 0; j < howManyItemsToTakeWhenArgMatches; j++) { + matchedArgIndexes.push(i + j); + } + } + } + + if (!matchedArgIndexes.length) { + return []; + } + + const nonPositionalsWithValues: NonPositional[] = []; + for (const idx of matchedArgIndexes) { + nonPositionalsWithValues.push({ + origIdx: idx, + argName: argv[idx], + }); + } + + const shouldRemoveArg = (idx: number) => matchedArgIndexes.includes(idx); + const argvIndexesToRemove: number[] = []; + + for (let i = 0; i < argv.length; i++) { + if (shouldRemoveArg(i)) { + argvIndexesToRemove.push(i); + } + } + + removeArrayValuesAtIndices(argv, argvIndexesToRemove); + + return nonPositionalsWithValues; +} + +export function eatNonPositionalsWithValues(argNames: string[], argv: Argv): NonPositionalWithValue[] { + const argsWithTheirValueAsNextItem: NonPositional[] = eatNonPositionals(argNames, argv, { + howManyItemsToTakeWhenArgMatches: 2, + }); + + assert.deepStrictEqual(argsWithTheirValueAsNextItem.length % 2, 0, `expected all arguments to have a value.`); + + const properArgsWithValues: NonPositionalWithValue[] = []; + for (let i = 0; i < argsWithTheirValueAsNextItem.length; i += 2) { + const arg = argsWithTheirValueAsNextItem[i]; + const val = argsWithTheirValueAsNextItem[i + 1]; + + properArgsWithValues.push({ + origIdx: arg.origIdx, + argName: arg.argName, + argVal: val.argName, + }); + } + + return properArgsWithValues; +} + +/** + * internal utils + */ + +export function removeArrayValuesAtIndices(arrayRef: T[], indexesToRemove: number[]): void { + /** + * go in reverse. + * + * because if went from 0 to length, + * removing an item from the array would adjust all other indices, + * which creates a mess & needs extra handling. + */ + const indexesBigToSmall = [...indexesToRemove].sort((A, B) => B - A); + + for (const idxToRemove of indexesBigToSmall) { + arrayRef.splice(idxToRemove, 1); + } + + return; +} + +/** + * common utilities for dealing w/ parsed values: + */ + +export function maybe( + x: T, // + Some: (x: T) => S, + None: (x?: never) => N +) { + if (x instanceof Array) { + return x.length ? Some(x) : None(); + } + + return x !== undefined ? Some(x) : None(); +} + +export const last = (xs: T[]): T => xs[xs.length - 1]; diff --git a/config.ts b/config.ts index 47b4db4e..89a9ecee 100644 --- a/config.ts +++ b/config.ts @@ -2,7 +2,7 @@ import Git from "nodegit"; -import { SomeOptionsForGitStackedRebase } from "./options"; +import { SpecifiableGitStackedRebaseOptions } from "./options"; import { getGitConfig__internal } from "./internal"; export const configKeyPrefix = "stackedrebase" as const; @@ -15,7 +15,7 @@ export const configKeys = { export async function loadGitConfig( repo: Git.Repository, - specifiedOptions: SomeOptionsForGitStackedRebase + specifiedOptions: SpecifiableGitStackedRebaseOptions ): Promise { return getGitConfig__internal in specifiedOptions ? await specifiedOptions[getGitConfig__internal]!({ GitConfig: Git.Config, repo }) @@ -23,17 +23,44 @@ export async function loadGitConfig( } export type ConfigValues = { - gpgSign: boolean; - autoApplyIfNeeded: boolean; - autoSquash: boolean; + gpgSign: boolean | undefined; + autoApplyIfNeeded: boolean | undefined; + autoSquash: boolean | undefined; }; -export async function parseGitConfigValues(config: Git.Config): Promise { +export async function resolveGitConfigValues(config: Git.Config): Promise { + const [ + gpgSign, // + autoApplyIfNeeded, + autoSquash, + ] = await Promise.all([ + resolveConfigBooleanValue(config.getBool(configKeys.gpgSign)), + resolveConfigBooleanValue(config.getBool(configKeys.autoApplyIfNeeded)), + resolveConfigBooleanValue(config.getBool(configKeys.autoSquash)), + ]); + const configValues: ConfigValues = { - gpgSign: !!(await config.getBool(configKeys.gpgSign).catch(() => 0)), - autoApplyIfNeeded: !!(await config.getBool(configKeys.autoApplyIfNeeded).catch(() => 0)), - autoSquash: !!(await config.getBool(configKeys.autoSquash).catch(() => 0)), + gpgSign, + autoApplyIfNeeded, + autoSquash, }; return configValues; } + +/** + * there's a difference between a value set to false (intentionally disabled), + * vs not set at all: + * can then look thru lower level options providers, and take their value. + * + * ``` + * export const handleConfigBooleanValue = (x: Promise) => x.then(Boolean).catch(() => undefined); + * ``` + * + * but actually, it doesn't matter here, because when we're trying to resolve (here), + * our goal is to provide a final value that will be used by the program, + * thus no `undefined`. + * + */ +// +export const resolveConfigBooleanValue = (x: Promise) => x.then(Boolean).catch(() => false); diff --git a/filenames.ts b/filenames.ts index 8ac1f7ea..0e3e9abe 100644 --- a/filenames.ts +++ b/filenames.ts @@ -10,6 +10,8 @@ export const filenames = { gitRebaseTodo: "git-rebase-todo", // postStackedRebaseHook: "post-stacked-rebase", + // + initialBranch: "initial-branch", /** * TODO extract others into here diff --git a/git-stacked-rebase.ts b/git-stacked-rebase.ts index 0d959a61..c94cf25e 100755 --- a/git-stacked-rebase.ts +++ b/git-stacked-rebase.ts @@ -14,14 +14,16 @@ import { bullets } from "nice-comment"; * separate package (soon) */ import { setupPostRewriteHookFor } from "./git-reconcile-rewritten-list/postRewriteHook"; +import { Argv, createArgParse, Maybe, maybe, last, MaybeArg } from "./argparse/argparse"; import { filenames } from "./filenames"; import { loadGitConfig } from "./config"; import { - getDefaultOptions, // - ParsedOptionsForGitStackedRebase, - parseOptions, - SomeOptionsForGitStackedRebase, + getDefaultResolvedOptions, // + ResolvedGitStackedRebaseOptions, + parseInitialBranch, + resolveOptions, + SpecifiableGitStackedRebaseOptions, } from "./options"; import { apply, applyIfNeedsToApply, markThatNeedsToApply } from "./apply"; import { forcePush } from "./forcePush"; @@ -55,38 +57,28 @@ import { export * from "./options"; export async function gitStackedRebase( - nameOfInitialBranch: string, - specifiedOptions: SomeOptionsForGitStackedRebase = {} + specifiedOptions: SpecifiableGitStackedRebaseOptions = {} ): Promise { try { - const repo: Git.Repository = await Git.Repository.open(specifiedOptions.gitDir || getDefaultOptions().gitDir); + const repo: Git.Repository = await Git.Repository.open(specifiedOptions.gitDir || getDefaultResolvedOptions().gitDir); const config: Git.Config = await loadGitConfig(repo, specifiedOptions); - - const options: ParsedOptionsForGitStackedRebase = await parseOptions(specifiedOptions, config); - - const execSyncInRepo = createExecSyncInRepo(repo); - const dotGitDirPath: string = repo.path(); - const pathToRegularRebaseDirInsideDotGit: string = path.join(dotGitDirPath, "rebase-merge"); - const pathToRegularRebaseTodoFile = path.join(pathToRegularRebaseDirInsideDotGit, filenames.gitRebaseTodo); + const options: ResolvedGitStackedRebaseOptions = await resolveOptions(specifiedOptions, { config, dotGitDirPath }); - const pathToStackedRebaseDirInsideDotGit: string = path.join(dotGitDirPath, "stacked-rebase"); - const pathToStackedRebaseTodoFile: string = path.join(pathToStackedRebaseDirInsideDotGit, filenames.gitRebaseTodo); + console.log({ options }); - const checkIsRegularRebaseStillInProgress = (): boolean => fs.existsSync(pathToRegularRebaseDirInsideDotGit); + const pathToRegularRebaseDirInsideDotGit = path.join(dotGitDirPath, "rebase-merge"); + const pathToStackedRebaseDirInsideDotGit = path.join(dotGitDirPath, "stacked-rebase"); - const initialBranch: Git.Reference | void = await Git.Branch.lookup( - repo, // - nameOfInitialBranch, - Git.Branch.BRANCH.ALL - ); - if (!initialBranch) { - throw new Error("initialBranch lookup failed"); - } + const pathToRegularRebaseTodoFile = path.join(pathToRegularRebaseDirInsideDotGit, filenames.gitRebaseTodo); + const pathToStackedRebaseTodoFile = path.join(pathToStackedRebaseDirInsideDotGit, filenames.gitRebaseTodo); + const initialBranch: Git.Reference = await parseInitialBranch(repo, options.initialBranch); const currentBranch: Git.Reference = await repo.getCurrentBranch(); + const execSyncInRepo = createExecSyncInRepo(repo); + const checkIsRegularRebaseStillInProgress = (): boolean => fs.existsSync(pathToRegularRebaseDirInsideDotGit); const askQuestion: AskQuestion = askQuestion__internal in options ? options[askQuestion__internal]! : question; if (fs.existsSync(path.join(pathToStackedRebaseDirInsideDotGit, filenames.willNeedToApply))) { @@ -1385,15 +1377,15 @@ git-stacked-rebase 2. but will not apply the changes to partial branches until --apply is used. -git-stacked-rebase [-a|--apply] +git-stacked-rebase [-a|--apply] - 1. will apply the changes to partial branches, - 2. but will not push any partial branches to a remote until --push is used. + 3. will apply the changes to partial branches, + 4. but will not push any partial branches to a remote until --push is used. -git-stacked-rebase [-p|--push -f|--force] +git-stacked-rebase [-p|--push -f|--force] - 1. will push partial branches with --force (and extra safety). + 5. will push partial branches with --force (and extra safety). @@ -1422,107 +1414,92 @@ git-stacked-rebase __VERSION_REPLACEMENT_STR__ __BUILD_DATE_REPLACEMENT_STR__ /** * the CLI */ -export async function git_stacked_rebase(): Promise { - process.argv.splice(0, 2); +export async function git_stacked_rebase(argv: Argv = process.argv.slice(2)): Promise { + try { + const options: SpecifiableGitStackedRebaseOptions = parseArgv(argv); + await gitStackedRebase(options); + process.exit(0); + } catch (e) { + const isKnownError = e instanceof Termination; + if (isKnownError) { + if (e.exitCode === 0) { + process.stdout.write(e.message); + } else { + process.stderr.write(e.message); + } - if (process.argv.some((arg) => ["-h", "--help"].includes(arg))) { - process.stdout.write(helpMsg); - return; + process.exit(e.exitCode); + } else { + process.stderr.write(e); + process.exit(1); + } } +} - if (process.argv.some((arg) => ["-V", "--version"].includes(arg))) { - process.stdout.write(`git-stacked-rebase __VERSION_REPLACEMENT_STR__\n`); - return; +export const parseArgs = (argvStr: string): SpecifiableGitStackedRebaseOptions => parseArgv(!argvStr?.trim() ? [] : argvStr.split(" ")); + +export function parseArgv(argv: Argv): SpecifiableGitStackedRebaseOptions { + const argp = createArgParse(argv); + + if (argp.eatNonPositionals(["-h", "--help"]).length) { + throw new Termination(helpMsg, 0); } - const isAutoSquash: boolean | undefined = eatOverwrittableArg("autosquash", process.argv); - - const peakNextArg = (): string | undefined => process.argv[0]; - const eatNextArg = (): string | undefined => process.argv.shift(); - - const eatValueOfNonPositionalArg = ( - argNameAndAliases: string[], - // argName: string | undefined = undefined, - indexOfArgVal: number | undefined = undefined, - argVal: string | undefined = undefined - ): typeof argVal => ( - (process.argv = process.argv - .map((arg, i, args) => - i === indexOfArgVal - ? false - : argNameAndAliases.includes(arg) - ? // ? (((argName = arg), (indexOfArgVal = i + 1), (argVal = args[i + 1])), false) - ((indexOfArgVal = i + 1), (argVal = args[i + 1]), false) - : arg - ) - .filter((arg) => arg !== false) as string[]), - argVal - ); + if (argp.eatNonPositionals(["-V", "--version"]).length) { + const msg = `git-stacked-rebase __VERSION_REPLACEMENT_STR__\n`; + throw new Termination(msg, 0); + } - /** - * need to read & get rid of non-positional args & their values first. - */ + const isAutoSquash: Maybe = maybe( + argp.eatNonPositionals(["--autosquash", "--no-autosquash"]), + (xs) => last(xs).argName === "--autosquash", + _ => undefined + ); /** * TODO use value directly from git's `git --git-dir` if possible? * (to get the default, probably) */ - const gitDir: string | undefined = eatValueOfNonPositionalArg(["--git-dir", "--gd"]); + const gitDir: MaybeArg = maybe( + argp.eatNonPositionalsWithValues(["--git-dir", "--gd"]), + (xs) => last(xs).argVal, + _ => undefined + ); - /** - * and now off to positional args. - */ - console.log({ "process.argv after non-positional": process.argv }); + const checkIsApply = (arg: MaybeArg): boolean => !!arg && ["--apply", "-a"].includes(arg); + const checkIsContinue = (arg: MaybeArg): boolean => !!arg && ["--continue", "-c"].includes(arg); + const checkIsPush = (arg: MaybeArg): boolean => !!arg && ["--push", "-p"].includes(arg); + const checkIsBranchSequencer = (arg: MaybeArg): boolean => + !!arg && ["--branch-sequencer", "--bs", "-s"].includes(arg); - const nameOfInitialBranch: string | undefined = eatNextArg(); - if (!nameOfInitialBranch) { - throw new Termination(helpMsg); - } + const checkIsSecondArg = (arg: MaybeArg): boolean => + checkIsApply(arg) || checkIsContinue(arg) || checkIsPush(arg) || checkIsBranchSequencer(arg); - if (["--continue", "-c"].includes(nameOfInitialBranch) && !process.argv.length) { - console.log("--continue without initialBranch"); + let nameOfInitialBranch: MaybeArg = argp.eatNextArg(); + let second: MaybeArg; - /** - * TODO allow `null` / make optional - * - * both will need some intellisense to only allow - * in specific cases - * - * (unless we'll keep track of the - * current initial branch we're working with?) - * - */ - const initialBranch = ""; + if (checkIsSecondArg(nameOfInitialBranch)) { + second = nameOfInitialBranch; + nameOfInitialBranch = undefined; + } else { + second = argp.eatNextArg(); - /** - * TODO call more appropraitely / extract default options - * so that it's less error-prone here - */ - return gitStackedRebase(initialBranch, { - gitDir, - continue: true, - }); + if (second && !checkIsSecondArg(second)) { + const msg = `\nunknown second arg "${second}".\n\n`; + throw new Termination(msg); + } } - /** - * TODO: improve arg parsing, lmao - */ - const second = peakNextArg(); - - const isApply: boolean = !!second && ["--apply", "-a"].includes(second); - const isContinue: boolean = !!second && ["--continue", "-c"].includes(second); - const isPush: boolean = !!second && ["--push", "-p"].includes(second); - const isBranchSequencer: boolean = !!second && ["--branch-sequencer", "--bs", "-s"].includes(second); - - if (isContinue || isApply || isPush || isBranchSequencer) { - eatNextArg(); - } + const isApply: boolean = checkIsApply(second); + const isContinue: boolean = checkIsContinue(second); + const isPush: boolean = checkIsPush(second); + const isBranchSequencer: boolean = checkIsBranchSequencer(second); let isForcePush: boolean = false; let branchSequencerExec: string | false = false; - if (peakNextArg() && (isPush || isBranchSequencer)) { - const third = eatNextArg() || ""; + if (argp.hasMoreArgs() && (isPush || isBranchSequencer)) { + const third = argp.eatNextArg() || ""; if (isPush) { isForcePush = ["--force", "-f"].includes(third); @@ -1536,31 +1513,27 @@ export async function git_stacked_rebase(): Promise { * */ const execNames = ["--exec", "-x"]; - if (execNames.includes(third) && peakNextArg()) { - const fourth = eatNextArg(); + if (execNames.includes(third) && argp.hasMoreArgs()) { + const fourth = argp.eatNextArg(); branchSequencerExec = fourth ? fourth : false; } else { - throw new Termination( - `\n--branch-sequencer can only (for now) be followed by ${execNames.join("|")}\n\n` - ); + const msg = `\n--branch-sequencer can only (for now) be followed by "${execNames.join("|")}".\n\n`; + throw new Termination(msg); } } if (!isForcePush && !branchSequencerExec) { - throw new Termination(`\nunrecognized 3th option (got "${third}")\n\n`); + throw new Termination(`\nunknown 3rd arg "${third}".\n\n`); } } - if (process.argv.length) { - throw new Termination( - "" + // - "\n" + - bullets("\nerror - leftover arguments: ", process.argv, " ") + - "\n\n" - ); + if (argv.length) { + const msg = "\n" + bullets("\nerror - leftover arguments: ", argv, " ") + "\n\n"; + throw new Termination(msg); } - const options: SomeOptionsForGitStackedRebase = { + const options: SpecifiableGitStackedRebaseOptions = { + initialBranch: nameOfInitialBranch, gitDir, autoSquash: isAutoSquash, apply: isApply, @@ -1571,46 +1544,9 @@ export async function git_stacked_rebase(): Promise { branchSequencerExec, }; - // await - return gitStackedRebase(nameOfInitialBranch, options); // -} - -/** - * mutates `argv` - removes the - * --{dashlessArgName} - * --no-{dashlessArgName} - * args. - * - * @returns {boolean|undefined} which arg prevailed (yes or no). - * last arg is the deciding one. - * if 0 args matched, returns undefined - * - */ -export function eatOverwrittableArg(dashlessArgName: string, argv: string[]): boolean | undefined { - const yesArgName = `--${dashlessArgName}`; - const noArgName = `--no-${dashlessArgName}`; - const yesNoArgs = [yesArgName, noArgName]; - - const matchedArgs = argv.filter((arg) => yesNoArgs.includes(arg)); - - if (!matchedArgs.length) { - return undefined; - } - - return matchedArgs[matchedArgs.length - 1] === yesArgName; + return options; } if (!module.parent) { - git_stacked_rebase() // - .then(() => process.exit(0)) - .catch((e) => { - if (e instanceof Termination) { - process.stderr.write(e.message); - process.exit(1); - } else { - console.error(e); - process.exit(1); - // throw e; - } - }); + git_stacked_rebase(); } diff --git a/options.ts b/options.ts index d3cc619c..6c80a149 100644 --- a/options.ts +++ b/options.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/camelcase */ +import fs from "fs"; +import path from "path"; + import Git from "nodegit"; import { bullets } from "nice-comment"; @@ -7,10 +10,17 @@ import { Termination } from "./util/error"; import { removeUndefinedProperties } from "./util/removeUndefinedProperties"; import { InternalOnlyOptions } from "./internal"; -import { parseGitConfigValues } from "./config"; +import { resolveGitConfigValues } from "./config"; +import { filenames } from "./filenames"; import { noop } from "./util/noop"; -export type BaseOptionsForGitStackedRebase = { +/** + * first, the required options: + * without them, GSR cannot function properly. + */ +export type _BaseOptionsForGitStackedRebase_Required = { + initialBranch: string; + gitDir: string; /** @@ -23,10 +33,12 @@ export type BaseOptionsForGitStackedRebase = { * that aren't natively supported by `nodegit` (libgit2) */ gitCmd: string; +}; - gpgSign?: boolean | undefined; - autoSquash?: boolean | undefined; - autoApplyIfNeeded?: boolean | undefined; +export type _BaseOptionsForGitStackedRebase_Optional = Partial<{ + gpgSign: boolean; + autoSquash: boolean; + autoApplyIfNeeded: boolean; apply: boolean; continue: boolean; @@ -35,43 +47,52 @@ export type BaseOptionsForGitStackedRebase = { branchSequencer: boolean; branchSequencerExec: string | false; -}; +}>; -/** - * the defaults (some optional) - */ -export type OptionsForGitStackedRebase = BaseOptionsForGitStackedRebase & InternalOnlyOptions; +export type ResolvedGitStackedRebaseOptions = Required<_BaseOptionsForGitStackedRebase_Optional> & + _BaseOptionsForGitStackedRebase_Required & + InternalOnlyOptions; /** * the specifiable ones in the library call (all optional) */ -export type SomeOptionsForGitStackedRebase = Partial; - -/** - * the parsed ones (0 optional) - */ -export type ParsedOptionsForGitStackedRebase = Required & InternalOnlyOptions; +export type SpecifiableGitStackedRebaseOptions = Partial; export const defaultEditor = "vi" as const; export const defaultGitCmd = "/usr/bin/env git" as const; -export async function parseOptions( - specifiedOptions: SomeOptionsForGitStackedRebase, // - config: Git.Config -): Promise { - const parsedOptions: ParsedOptionsForGitStackedRebase = { +export type ResolveOptionsCtx = { + config: Git.Config; + dotGitDirPath: string; +}; + +export async function resolveOptions( + specifiedOptions: SpecifiableGitStackedRebaseOptions, // + { + config, // + dotGitDirPath, + }: ResolveOptionsCtx +): Promise { + const resolvedOptions: ResolvedGitStackedRebaseOptions = { /** - * order matters + * order matters for what takes priority. */ - ...getDefaultOptions(), - ...(await parseGitConfigValues(config)), + ...getDefaultResolvedOptions(), + ...(await resolveGitConfigValues(config)), ...removeUndefinedProperties(specifiedOptions), - }; - console.log({ parsedOptions }); + /** + * the `initialBranch` arg is taken from `specifiedOptions`, instead of `resolvedOptions`, + * because we do want to throw the error if the user didn't specify & does not have cached. + */ + initialBranch: resolveInitialBranchNameFromProvidedOrCache({ + initialBranch: specifiedOptions.initialBranch, // + dotGitDirPath, + }), + }; const reasonsWhatWhyIncompatible: string[] = []; - if (areOptionsIncompatible(parsedOptions, reasonsWhatWhyIncompatible)) { + if (areOptionsIncompatible(resolvedOptions, reasonsWhatWhyIncompatible)) { const msg = "\n" + bullets( @@ -83,17 +104,19 @@ export async function parseOptions( throw new Termination(msg); } - return parsedOptions; + return resolvedOptions; } -export const getDefaultOptions = (): OptionsForGitStackedRebase => ({ +export const getDefaultResolvedOptions = (): ResolvedGitStackedRebaseOptions => ({ + initialBranch: "origin/master", + // gitDir: ".", // gitCmd: process.env.GIT_CMD ?? defaultGitCmd, editor: process.env.EDITOR ?? defaultEditor, // - gpgSign: undefined, - autoSquash: undefined, - autoApplyIfNeeded: undefined, + gpgSign: false, + autoSquash: false, + autoApplyIfNeeded: false, // apply: false, // @@ -107,7 +130,7 @@ export const getDefaultOptions = (): OptionsForGitStackedRebase => ({ }); export function areOptionsIncompatible( - options: ParsedOptionsForGitStackedRebase, // + options: ResolvedGitStackedRebaseOptions, // reasons: string[] = [] ): boolean { noop(options); @@ -117,3 +140,49 @@ export function areOptionsIncompatible( return reasons.length > 0; } + +export type ResolveInitialBranchNameFromProvidedOrCacheCtx = { + initialBranch?: SpecifiableGitStackedRebaseOptions["initialBranch"]; + dotGitDirPath: string; +}; + +export function resolveInitialBranchNameFromProvidedOrCache({ + initialBranch, // + dotGitDirPath, +}: ResolveInitialBranchNameFromProvidedOrCacheCtx): string { + const pathToStackedRebaseDirInsideDotGit: string = path.join(dotGitDirPath, "stacked-rebase"); + const initialBranchCachePath: string = path.join(pathToStackedRebaseDirInsideDotGit, filenames.initialBranch); + + fs.mkdirSync(pathToStackedRebaseDirInsideDotGit, { recursive: true }); + + const hasCached = () => fs.existsSync(initialBranchCachePath); + const setCache = (initialBranch: string) => fs.writeFileSync(initialBranchCachePath, initialBranch); + const getCache = (): string => fs.readFileSync(initialBranchCachePath).toString(); + + if (initialBranch) { + setCache(initialBranch); + return initialBranch; + } + + if (hasCached()) { + return getCache(); + } else { + /** + * TODO: try from config if default initial branch is specified, + * if yes - check if is here, if yes - ask user if start from there. + * if no - throw + */ + const msg = `\ndefault argument of the initial branch is required.\n\n`; + throw new Termination(msg); + } +} + +export async function parseInitialBranch(repo: Git.Repository, nameOfInitialBranch: string): Promise { + const initialBranch: Git.Reference | void = await Git.Branch.lookup(repo, nameOfInitialBranch, Git.Branch.BRANCH.ALL); + + if (!initialBranch) { + throw new Termination("initialBranch lookup failed"); + } + + return initialBranch; +} diff --git a/parse-todo-of-stacked-rebase/parseNewGoodCommands.spec.ts b/parse-todo-of-stacked-rebase/parseNewGoodCommands.spec.ts index 82e2fd7f..d68ccb2e 100644 --- a/parse-todo-of-stacked-rebase/parseNewGoodCommands.spec.ts +++ b/parse-todo-of-stacked-rebase/parseNewGoodCommands.spec.ts @@ -16,9 +16,9 @@ export async function parseNewGoodCommandsSpec(): Promise { await succeeds_to_apply_after_explicit_drop(); async function succeeds_to_apply_after_break_or_exec(): Promise { - const { initialBranch, common, commitsInLatest } = await setupRepo(); + const { common, commitsInLatest } = await setupRepo(); - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, [editor__internal]: ({ filePath }) => { humanOpAppendLineAfterNthCommit("break", { @@ -28,16 +28,16 @@ export async function parseNewGoodCommandsSpec(): Promise { }, }); - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, apply: true, }); } async function succeeds_to_apply_after_implicit_drop(): Promise { - const { initialBranch, common, commitsInLatest } = await setupRepo(); + const { common, commitsInLatest } = await setupRepo(); - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, [editor__internal]: ({ filePath }) => { humanOpRemoveLineOfCommit({ @@ -47,16 +47,16 @@ export async function parseNewGoodCommandsSpec(): Promise { }, }); - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, apply: true, }); } async function succeeds_to_apply_after_explicit_drop(): Promise { - const { initialBranch, common, commitsInLatest } = await setupRepo(); + const { common, commitsInLatest } = await setupRepo(); - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, [editor__internal]: ({ filePath }) => { humanOpChangeCommandOfNthCommitInto("drop", { @@ -66,7 +66,7 @@ export async function parseNewGoodCommandsSpec(): Promise { }, }); - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, apply: true, }); diff --git a/test/apply.spec.ts b/test/apply.spec.ts index b810c50d..9f0b5870 100644 --- a/test/apply.spec.ts +++ b/test/apply.spec.ts @@ -20,7 +20,7 @@ export async function applyTC() { * create a scenario where an apply is needed, and disallow it - GSR should exit. */ async function integration__git_stacked_rebase_exits_if_apply_was_needed_but_user_disallowed() { - const { initialBranch, common, commitsInLatest, config, repo } = await setupRepo(); + const { common, commitsInLatest, config, repo } = await setupRepo(); /** * ensure autoApplyIfNeeded is disabled @@ -30,7 +30,7 @@ async function integration__git_stacked_rebase_exits_if_apply_was_needed_but_use /** * force modify history, so that an apply will be needed */ - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, [editor__internal]: ({ filePath }) => { humanOpChangeCommandOfNthCommitInto("drop", { @@ -57,7 +57,7 @@ async function integration__git_stacked_rebase_exits_if_apply_was_needed_but_use * and autoApplyIfNeeded is disabled, * we should get prompted to allow the apply. */ - gitStackedRebase(initialBranch, { + gitStackedRebase({ ...common, ...noEditor, [askQuestion__internal]: (q, ...rest) => { diff --git a/test/auto-checkout-remote-partial-branches.spec.ts b/test/auto-checkout-remote-partial-branches.spec.ts index 2ff565e1..8b2594f8 100755 --- a/test/auto-checkout-remote-partial-branches.spec.ts +++ b/test/auto-checkout-remote-partial-branches.spec.ts @@ -40,7 +40,8 @@ async function auto_checks_out_remote_partial_branches() { "expected partial branches to __not be__ checked out locally, to be able to test later that they will be." ); - await gitStackedRebase(RemoteAlice.initialBranch, { + await gitStackedRebase({ + initialBranch: RemoteAlice.initialBranch, gitDir: LocalBob.repo.workdir(), [editor__internal]: () => void 0 /** no edit */, }); @@ -90,7 +91,8 @@ async function give_chosen_name_to_local_branch() { "expected partial branches to __not be__ checked out locally, to be able to test later that they will be." ); - await gitStackedRebase(RemoteAlice.initialBranch, { + await gitStackedRebase({ + initialBranch: RemoteAlice.initialBranch, gitDir: LocalBob.repo.workdir(), [editor__internal]: ({filePath}) => { const branchNameOf2ndBranch: string = RemoteAlice.newPartialBranches[1][0]; diff --git a/test/experiment.spec.ts b/test/experiment.spec.ts index 4d448f8d..11265d36 100755 --- a/test/experiment.spec.ts +++ b/test/experiment.spec.ts @@ -13,7 +13,6 @@ import { editor__internal } from "../internal"; export async function testCase(): Promise { const { - initialBranch, // common, commitsInLatest, read, @@ -28,7 +27,7 @@ export async function testCase(): Promise { const nthCommit2ndRebase = 5; - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, [editor__internal]: async ({ filePath }) => { humanOpChangeCommandOfNthCommitInto("edit", { @@ -69,7 +68,7 @@ export async function testCase(): Promise { console.log("attempting early 3rd rebase to --apply"); read(); - await gitStackedRebase(initialBranch, { + await gitStackedRebase({ ...common, apply: true, }); diff --git a/test/non-first-rebase-has-initial-branch-cached.spec.ts b/test/non-first-rebase-has-initial-branch-cached.spec.ts new file mode 100755 index 00000000..82ec17ee --- /dev/null +++ b/test/non-first-rebase-has-initial-branch-cached.spec.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env ts-node-dev + +import fs from "fs"; +import path from "path"; +import assert from "assert"; + +import { gitStackedRebase } from "../git-stacked-rebase"; + +import { setupRepo } from "./util/setupRepo"; +import { isMarkedThatNeedsToApply } from "../apply"; +import { filenames } from "../filenames"; +import { askQuestion__internal, editor__internal, noEditor } from "../internal"; +import { humanOpChangeCommandOfNthCommitInto } from "../humanOp"; +import { Questions, question } from "../util/createQuestion"; + +export async function nonFirstRebaseHasInitialBranchCached_TC() { + await scenario1(); +} + +async function scenario1() { + const { common, repo, commitsInLatest } = await setupRepo(); + + const initialBranch = "master" as const; + + await gitStackedRebase({ + ...common, + initialBranch, + apply: false, + autoApplyIfNeeded: false, + [editor__internal]: ({ filePath }) => { + /** + * force an apply to be needed, so that a second rebase is meaningful + */ + humanOpChangeCommandOfNthCommitInto("drop", { + filePath, // + commitSHA: commitsInLatest[2], + }); + }, + }); + + // BEGIN COPY_PASTE + // TODO take from `gitStackedRebase`: + const dotGitDirPath: string = repo.path(); + const pathToStackedRebaseDirInsideDotGit: string = path.join(dotGitDirPath, "stacked-rebase"); + assert.deepStrictEqual( + isMarkedThatNeedsToApply(pathToStackedRebaseDirInsideDotGit), // + true, + `expected a "needs-to-apply" mark to be set.` + ); + // END COPY_PASTE + + const pathToCache: string = path.join(pathToStackedRebaseDirInsideDotGit, filenames.initialBranch); + const isCached: boolean = fs.existsSync(pathToCache); + assert.deepStrictEqual(isCached, true, `expected initial branch to be cached after 1st run.`); + + const cachedValue: string = fs.readFileSync(pathToCache, { encoding: "utf-8" }); + assert.deepStrictEqual( + cachedValue, + initialBranch, + `expected the correct value to be cached ("${initialBranch}"), but found "${cachedValue}".` + ); + + console.log("performing 2nd rebase, expecting it to re-use the cached value of the initialBranch successfully."); + + await gitStackedRebase({ + ...common, + /** + * force unset initial branch + */ + initialBranch: undefined, + ...noEditor, + [askQuestion__internal]: (q, ...rest) => { + if (q === Questions.need_to_apply_before_continuing) return "y"; + return question(q, ...rest); + }, + }); +} + +if (!module.parent) { + nonFirstRebaseHasInitialBranchCached_TC(); +} diff --git a/test/parse-argv-resolve-options.spec.ts b/test/parse-argv-resolve-options.spec.ts new file mode 100755 index 00000000..b7ae389a --- /dev/null +++ b/test/parse-argv-resolve-options.spec.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env ts-node-dev + +import fs from "fs"; +import path from "path"; +import os from "os"; +import assert from "assert"; + +import Git from "nodegit"; + +import { + ResolvedGitStackedRebaseOptions, + getDefaultResolvedOptions, + parseArgs, + parseArgv, + resolveOptions, +} from "../git-stacked-rebase"; + +import { rmdirSyncR } from "../util/fs"; + +export async function parseArgvResolveOptions_TC() { + for (const testData of simpleTests) { + console.log({ testData }); + await runSimpleTest(...testData); + } +} + +type SimpleTestInput = [ + specifiedOptions: Parameters[0], + expectedOptions: Partial +]; + +/** + * TODO: + * - [ ] custom setup, i.e. a function w/ context that's run before parsing the options, to e.g. modify the config + * - [ ] a way to handle Termination's, throw's in general + * - [ ] multiple rebases one after another, to e.g. test that initialBranch is not needed for 2nd invocation + * + * prolly better to have separate file for more advanced tests, & keep this one simple + */ +const simpleTests: SimpleTestInput[] = [ + /** ensure defaults produce the same defaults: */ + [["origin/master"], {}], + ["origin/master", {}], + + ["custom-branch", { initialBranch: "custom-branch" }], + + ["origin/master -a", { apply: true }], + ["origin/master --apply", { apply: true }], + + ["origin/master -p -f", { push: true, forcePush: true }], + ["origin/master --push --force", { push: true, forcePush: true }], + + ["origin/master --continue", { continue: true }], + + ["origin/master", { autoSquash: false }], + ["origin/master --autosquash", { autoSquash: true }], + ["origin/master --autosquash --no-autosquash", { autoSquash: false }], + ["origin/master --autosquash --no-autosquash --autosquash", { autoSquash: true }], + ["origin/master --autosquash --no-autosquash --autosquash --no-autosquash", { autoSquash: false }], + + ["origin/master -s -x ls", { branchSequencer: true, branchSequencerExec: "ls" }], + ["origin/master --bs -x ls", { branchSequencer: true, branchSequencerExec: "ls" }], + [ + /** TODO E2E: test if paths to custom scripts work & in general if works as expected */ + "origin/master --branch-sequencer --exec ./custom-script.sh", + { branchSequencer: true, branchSequencerExec: "./custom-script.sh" }, + ], +]; + +async function runSimpleTest(specifiedOptions: SimpleTestInput[0], expectedOptions: SimpleTestInput[1]) { + const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "argv-options-test")); + const tmpfile = path.join(tmpdir, ".git-config"); + const tmpGitConfig: Git.Config = await Git.Config.openOndisk(tmpfile); + + const parsedArgv = typeof specifiedOptions === "string" ? parseArgs(specifiedOptions) : parseArgv(specifiedOptions); + console.log({ parsedArgv }); + + const resolvedOptions: ResolvedGitStackedRebaseOptions = await resolveOptions(parsedArgv, { + config: tmpGitConfig, // + dotGitDirPath: path.join(tmpdir, ".git"), + }); + + const fullExpectedOptions: ResolvedGitStackedRebaseOptions = { ...getDefaultResolvedOptions(), ...expectedOptions }; + assert.deepStrictEqual(resolvedOptions, fullExpectedOptions); + + // cleanup + rmdirSyncR(tmpdir); +} + +if (!module.parent) { + parseArgvResolveOptions_TC(); +} diff --git a/test/run.ts b/test/run.ts index bf8c7be6..7e0f035f 100644 --- a/test/run.ts +++ b/test/run.ts @@ -5,12 +5,23 @@ import reducePathTC from "../git-reconcile-rewritten-list/reducePath.spec"; import { parseNewGoodCommandsSpec } from "../parse-todo-of-stacked-rebase/parseNewGoodCommands.spec"; import autoCheckoutRemotePartialBranchesTC from "./auto-checkout-remote-partial-branches.spec"; import { applyTC } from "./apply.spec"; +import { argparse_TC } from "../argparse/argparse.spec"; +import { parseArgvResolveOptions_TC } from "./parse-argv-resolve-options.spec"; +import { nonFirstRebaseHasInitialBranchCached_TC } from "./non-first-rebase-has-initial-branch-cached.spec"; import { sequentialResolve } from "../util/sequentialResolve"; import { cleanupTmpRepos } from "./util/tmpdir"; main(); function main() { + process.on("uncaughtException", (e) => { + printErrorAndExit(e); + }); + + process.on("unhandledRejection", (e) => { + printErrorAndExit(e); + }); + // TODO Promise.all sequentialResolve([ testCase, // @@ -18,14 +29,24 @@ function main() { parseNewGoodCommandsSpec, autoCheckoutRemotePartialBranchesTC, applyTC, + async () => argparse_TC(), + parseArgvResolveOptions_TC, + nonFirstRebaseHasInitialBranchCached_TC, ]) .then(cleanupTmpRepos) .then(() => { process.stdout.write("\nsuccess\n\n"); process.exit(0); }) - .catch((e) => { - process.stderr.write("\nfailure: " + e + "\n\n"); - process.exit(1); - }); + .catch(printErrorAndExit); +} + +function printErrorAndExit(e: unknown) { + console.error(e); + + console.log("\nfull trace:"); + console.trace(e); + + process.stdout.write("\nfailure\n\n"); + process.exit(1); } diff --git a/test/util/setupRepo.ts b/test/util/setupRepo.ts index 7ae21d67..8d129fa6 100644 --- a/test/util/setupRepo.ts +++ b/test/util/setupRepo.ts @@ -7,7 +7,7 @@ import assert from "assert"; import Git from "nodegit"; import { gitStackedRebase } from "../../git-stacked-rebase"; -import { defaultGitCmd, SomeOptionsForGitStackedRebase } from "../../options"; +import { defaultGitCmd } from "../../options"; import { configKeys } from "../../config"; import { humanOpAppendLineAfterNthCommit } from "../../humanOp"; import { editor__internal, getGitConfig__internal } from "../../internal"; @@ -37,24 +37,32 @@ export async function setupRepo({ }: Opts = {}) { const ctx: SetupRepoOpts = { ...rest, bare: 0 }; - const base = await (initRepoBase?.(ctx) ?? setupRepoBase(ctx)); + const { common: _common, ...base } = await (initRepoBase?.(ctx) ?? setupRepoBase(ctx)); - const initialCommitId = "Initial-commit-from-setupRepo"; - const initialCommitFilePath = path.join(base.dir, initialCommitId); - const relFilepaths = [initialCommitId]; - - fs.writeFileSync(initialCommitFilePath, initialCommitId); - const initialCommit: Git.Oid = await base.repo.createCommitOnHead( - relFilepaths, // - base.sig, - base.sig, - initialCommitId - ); - - console.log("initial commit %s", initialCommit.tostrS()); + /** + * create a single initial commit, + * so that other git ops work as expected. + */ + await createInitialCommit(); + + async function createInitialCommit() { + const initialCommitId = "Initial-commit-from-setupRepo"; + const initialCommitFilePath = path.join(base.dir, initialCommitId); + const relFilepaths = [initialCommitId]; + + fs.writeFileSync(initialCommitFilePath, initialCommitId); + const initialCommit: Git.Oid = await base.repo.createCommitOnHead( + relFilepaths, // + base.sig, + base.sig, + initialCommitId + ); + + console.log("initial commit %s", initialCommit.tostrS()); + } const commitOidsInInitial: Git.Oid[] = []; - const initialBranchRef: Git.Reference = await appendCommitsTo( + await appendCommitsTo( commitOidsInInitial, // 3, base.repo, @@ -62,7 +70,15 @@ export async function setupRepo({ base.dir ); + const initialBranchRef: Git.Reference = await base.repo.getCurrentBranch(); + const initialBranch: string = initialBranchRef.shorthand(); + + const common = { + ..._common, + initialBranch, + } as const; + const commitsInInitial: string[] = commitOidsInInitial.map((oid) => oid.tostrS()); const latestStackedBranchName = "stack-latest"; @@ -86,8 +102,8 @@ export async function setupRepo({ ] as const; console.log("launching 0th rebase to create partial branches"); - await gitStackedRebase(initialBranch, { - ...base.common, + await gitStackedRebase({ + ...common, [editor__internal]: ({ filePath }) => { console.log("filePath %s", filePath); @@ -118,6 +134,7 @@ export async function setupRepo({ return { ...base, + common, read, // TODO move to base initialBranchRef, @@ -185,10 +202,10 @@ export async function setupRepoBase({ * esp. for running tests * (consumes the provided config, etc) */ - const common: SomeOptionsForGitStackedRebase = { + const common = { gitDir: dir, [getGitConfig__internal]: () => config, - } as const; + } as const; // satisfies SomeOptionsForGitStackedRebase; // TODO const getBranchNames = nativeGetBranchNames(repo.workdir()); diff --git a/tsconfig.json b/tsconfig.json index 833ced5a..70c5e6b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,7 +36,11 @@ }, "exclude": [ "node_modules", // - "dist" + "dist", + /** + * ignore test, because of annoying and incorrect error: TS4058 + */ + "test" ], "include": [ // diff --git a/util/error.ts b/util/error.ts index aca1fe11..97e22e28 100644 --- a/util/error.ts +++ b/util/error.ts @@ -1 +1,5 @@ -export class Termination extends Error {} +export class Termination extends Error { + constructor(public message: string, public exitCode: number = 1) { + super(message); + } +} diff --git a/util/fs.ts b/util/fs.ts index 2704cf03..1fe2e8a9 100644 --- a/util/fs.ts +++ b/util/fs.ts @@ -1,3 +1,6 @@ import fs from "fs"; export const isDirEmptySync = (dirPath: fs.PathLike): boolean => fs.readdirSync(dirPath).length === 0; + +/** node mantainers are a-holes for this breaking change */ +export const rmdirSyncR = (dir: fs.PathLike) => fs.rmdirSync(dir, { recursive: true, ...{ force: true } }); diff --git a/util/removeUndefinedProperties.ts b/util/removeUndefinedProperties.ts index 7cc5a08c..db311737 100644 --- a/util/removeUndefinedProperties.ts +++ b/util/removeUndefinedProperties.ts @@ -1,13 +1,16 @@ -export function removeUndefinedProperties>( - object: Partial // -): Partial { - return ( - Object.keys(object).forEach( - (k) => - k in object && // - object[k as K] === undefined && - delete object[k as K] - ), - object - ); +export function removeUndefinedProperties>(object: U): T { + for (const key in object) { + if (object[key] === undefined) { + delete object[key]; + } + } + + return object as unknown as T; + /** + * TODO TS - we're not doing what we're saying we're doing here. + * + * we're simply deleting undefined properties, + * but in the type system, we're saying that "we are adding legit values to properties who were undefined", + * which is obviously incorrect. + */ }