diff --git a/README.md b/README.md index 7f4ce87..951773f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ keep the history around for a bit longer. The library uses json-patch RFC6902 fo - Checkout - Reset (soft and hard) - Diff +- Status +- Apply + > ⚠️ Setting a value for an undefined prop `{prop: undefined}` will result in the `compare` call as a `replace` + operation, but will be recorded by the observer as + an `add` operation. See https://github.com/Starcounter-Jack/JSON-Patch/issues/280 for details - Visualization via `@dotinc/ogre-react` - Merge - fast-forward diff --git a/packages/ogre/src/commit.test.ts b/packages/ogre/src/commit.test.ts index eac941c..9c07de4 100644 --- a/packages/ogre/src/commit.test.ts +++ b/packages/ogre/src/commit.test.ts @@ -7,6 +7,7 @@ import { testAuthor, updateHeaderData, } from "./test.utils"; +import { printChangeLog } from "./repository"; test("baseline with 1 commit and zero changelog entries", async (t) => { const [repo] = await getBaseline(); @@ -40,6 +41,23 @@ test("no commit without changes after recent commit", async (t) => { }); }); +test("overwrite nested array changes are recognized", async (t) => { + const [repo] = await getBaseline(); + repo.data.name = "new name"; + await repo.commit("baseline", testAuthor); + repo.data.nested = [{ name: "new item", uuid: "asdf" }]; + await repo.commit("overwrite nested array", testAuthor); +}); + +test("change of nested array element is recognized", async (t) => { + const [repo] = await getBaseline(); + repo.data.name = "new name"; + addOneStep(repo.data); + await repo.commit("baseline", testAuthor); + repo.data.nested[0].name = "another name which is different"; + await repo.commit("changed nested array object", testAuthor); +}); + test("treeHash of commit is matching content", async (t) => { const [repo] = await getBaseline(); repo.data.name = "new name"; diff --git a/packages/ogre/src/index.ts b/packages/ogre/src/index.ts index 7385566..c8d830b 100644 --- a/packages/ogre/src/index.ts +++ b/packages/ogre/src/index.ts @@ -5,3 +5,5 @@ export * from "./ref"; export * from "./size"; export * from "./utils"; export * from "./git2json"; + +export { compare, deepClone, Operation, JsonPatchError } from "fast-json-patch"; diff --git a/packages/ogre/src/repository.test.ts b/packages/ogre/src/repository.test.ts index 789ebe4..ba63f9c 100644 --- a/packages/ogre/src/repository.test.ts +++ b/packages/ogre/src/repository.test.ts @@ -1,7 +1,8 @@ import { test } from "tap"; -import { Repository } from "./repository"; +import { printChange, printChangeLog, Repository } from "./repository"; import { + addOneStep, addOneStep as addOneNested, ComplexObject, getBaseline, @@ -10,6 +11,7 @@ import { updateHeaderData, } from "./test.utils"; import { History, Reference } from "./interfaces"; +import { compare, Operation } from "fast-json-patch"; test("diff is ok", async (t) => { const [repo, obj] = await getBaseline(); @@ -188,3 +190,146 @@ test("reset", async (t) => { t.equal(diff2.length, 0, "failed to reset"); }); }); + +test("status", async (t) => { + t.test("clean repo no change", async (t) => { + const [repo] = await getBaseline(); + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + }); + t.test("clean repo pending change", async (t) => { + const [repo] = await getBaseline({ name: "base name" }); + repo.data.name = "changed name"; + const dirtyState = repo.status(); + t.equal(dirtyState.length, 1, "Status doesn't contain changes"); + }); + t.test("reading status doesn't clean observer", async (t) => { + const [repo] = await getBaseline({ name: "base name" }); + repo.data.name = "changed name"; + const dirtyState = repo.status(); + t.equal(dirtyState.length, 1, "Status doesn't contain changes"); + + const dirtyState2 = repo.status(); + t.equal(dirtyState2.length, 1, "Status doesn't contain changes"); + t.match(dirtyState2, dirtyState2, "different pending changes??"); + }); + t.test("after commit no change", async (t) => { + const [repo] = await getBaseline(); + repo.data.name = "new name"; + await repo.commit("baseline", testAuthor); + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + }); + t.test("after commit pending change", async (t) => { + const [repo] = await getBaseline(); + repo.data.name = "new name"; + await repo.commit("baseline", testAuthor); + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + repo.data.name = "changed name"; + const dirtyState = repo.status(); + t.equal(dirtyState.length, 1, "Status doesn't contain changes"); + }); + t.test("after commit pending change for rewrite array", async (t) => { + const [repo] = await getBaseline(); + repo.data.name = "new name"; + await repo.commit("baseline", testAuthor); + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + repo.data.nested = [{ name: "new item", uuid: "asdf" }]; + const dirtyState = repo.status(); + t.equal(dirtyState.length, 1, "Status doesn't contain changes"); + }); + t.test("change of nested array element prop", async (t) => { + const [repo] = await getBaseline(); + repo.data.name = "new name"; + addOneStep(repo.data); + await repo.commit("baseline", testAuthor); + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + repo.data.nested[0].name = "another name which is different"; + const dirtyState = repo.status(); + t.equal(dirtyState?.length, 1, "Status doesn't contain changes"); + }); +}); + +test("apply", async (t) => { + t.test("single patch", async (t) => { + const [repo] = await getBaseline({ name: "base name" }); + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + + const targetState = { + uuid: undefined, + name: "a name", + description: undefined, + nested: [{ name: "new item", uuid: "asdf" }], + }; + const patches = compare(repo.data, targetState); + // this should record changes on the observer + const err = repo.apply(patches); + t.match(err, undefined, "Failed to apply patch"); + t.match(repo.data, targetState, "The final state does not match up"); + const dirtyState = repo.status(); + t.equal(dirtyState.length, 2, "Status doesn't contain changes"); + t.match(dirtyState, patches, "It should have the right changes"); + }); + + t.test( + "patch for undefined props doesn't work as expected https://github.com/Starcounter-Jack/JSON-Patch/issues/280", + async (t) => { + const [repo] = await getBaseline(); + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + + const targetState: ComplexObject = { + uuid: undefined, + description: undefined, + name: "a name", + nested: [], + }; + const patches = compare(repo.data, targetState); + // this should record changes on the observer + const err = repo.apply(patches); + t.match( + err, + { + name: "OPERATION_PATH_UNRESOLVABLE", + operation: { + op: "replace", + path: "/name", + value: "a name", + }, + }, + "Failed to apply patch", + ); + t.notMatch( + repo.data, + targetState, + "The final state shoould not match up, because patch failed", + ); + const dirtyState = repo.status(); + t.equal(dirtyState.length, 0, "Status doesn't contain changes"); + }, + ); + + t.test("multiple patches", async (t) => { + const [repo] = await getBaseline({ name: "base name" }); + + const cleanState = repo.status(); + t.match(cleanState, [], "Shouldn't have pending changes"); + const targetState = { + uuid: undefined, + name: "a name", + description: undefined, + nested: [{ name: "new item", uuid: "asdf" }], + }; + const patches = compare(repo.data, targetState); + const err = repo.apply(patches); + t.equal(err, undefined, "Failed to apply patch"); + const dirtyState = repo.status(); + t.equal(dirtyState?.length, 2, "Status doesn't contain changes"); + t.match(dirtyState, patches, "It should have the right changes"); + t.match(repo.data, targetState, "The final state does not match up"); + }); +}); diff --git a/packages/ogre/src/repository.ts b/packages/ogre/src/repository.ts index 64d7698..2eed6ca 100644 --- a/packages/ogre/src/repository.ts +++ b/packages/ogre/src/repository.ts @@ -7,6 +7,9 @@ import { generate, Observer, Operation, + validate, + applyPatch, + JsonPatchError, } from "fast-json-patch"; import { calculateCommitHash, Commit } from "./commit"; import { History, Reference } from "./interfaces"; @@ -37,6 +40,17 @@ export interface RepositoryObject { */ diff(shaishFrom: string, shaishTo?: string): Operation[]; + /** + * Returns pending changes. + */ + status(): Operation[]; + + /** + * Applies a patch to the repository's HEAD + * @param patch + */ + apply(patch: Operation[]): void; + // It returns the reference where we are currently at head(): string; @@ -116,11 +130,20 @@ export class Repository if (!patchToTarget || patchToTarget.length < 1) { return; } - unobserve(this.data, this.observer); + this.observer.unobserve(); patchToTarget.reduce(applyReducer, this.data); this.observer = observe(this.data); } + apply(patch: Operation[]): JsonPatchError | undefined { + const err = validate(patch, this.data); + if (err) { + return err; + } + applyPatch(this.data, patch); + // const changed = patch.reduce(applyReducer, this.data); + } + reset( mode: "soft" | "hard" | undefined = "hard", shaish: string | undefined = REFS_HEAD_KEY, @@ -161,6 +184,15 @@ export class Repository return REFS_HEAD_KEY; // detached state } + status() { + const commit = this.commitAtHead(); + if (!commit) { + // on root repo return the pending changes + return compare(this.original, this.data); // this.observer.patches is empty?? :( + } + return this.diff(commit.hash); + } + diff(shaishFrom: string, shaishTo?: string): Operation[] { const [cFrom] = shaishToCommit(shaishFrom, this.refs, this.commits); let target: T; @@ -455,21 +487,6 @@ const treeToObject = (tree: string): T => { const getLastItem = (thePath: string) => thePath.substring(thePath.lastIndexOf("/") + 1); -/** - * Traverses the commit tree backwards and reassembles the changelog - * @param commit - * @param commitsList - */ -const traverseAndCollectChangelog = (commit: Commit, commitsList: Commit[]) => { - let c: Commit | undefined = commit; - let clog: Operation[] = []; - while (c !== undefined) { - clog = [...commit.changes, ...clog]; - c = commitsList.find((parent) => parent.hash === c?.parent); - } - return clog; -}; - const mapPath = ( from: Commit, to: Commit, @@ -613,16 +630,29 @@ export const printChangeLog = ( repository: RepositoryObject, ) => { console.log("----------------------------------------------------------"); - console.log(`Changelog at ${repository.head()}`); + console.log("Changelog"); + console.log("----------------------------------------------------------"); const history = repository.getHistory(); const head = commitAtRefIn(repository.head(), history.refs, history.commits); if (!head) { throw new Error(`fatal: HEAD is not defined`); } - const changeLog = traverseAndCollectChangelog(head, history.commits); - for (const [, chg] of changeLog.entries()) { - console.log(` ${JSON.stringify(chg)}`); + let c: Commit | undefined = head; + while (c) { + console.log( + `${c.hash} ${refsAtCommit(history.refs, c) + .map((r) => r.name) + .join(" ")}`, + ); + for (const chg of c.changes) { + printChange(chg); + } + c = history.commits.find((parent) => parent.hash === c?.parent); } - + console.log("End of changelog"); console.log("----------------------------------------------------------"); }; + +export const printChange = (chg: Operation) => { + console.log(` ${JSON.stringify(chg)}`); +}; diff --git a/packages/ogre/src/test.utils.ts b/packages/ogre/src/test.utils.ts index 4542749..4908298 100644 --- a/packages/ogre/src/test.utils.ts +++ b/packages/ogre/src/test.utils.ts @@ -16,14 +16,15 @@ export type ComplexObject = { export const testAuthor = "User name "; -export async function getBaseline(): Promise< - [RepositoryObject, ComplexObject] -> { +export async function getBaseline( + obj?: Partial, +): Promise<[RepositoryObject, ComplexObject]> { const co: ComplexObject = { uuid: undefined, name: undefined, description: undefined, nested: [], + ...obj, }; const repo = new Repository(co, {}); return [repo, co];