diff --git a/src/plugins/git/porcelain.js b/src/plugins/git/porcelain.js new file mode 100644 index 0000000..03243f6 --- /dev/null +++ b/src/plugins/git/porcelain.js @@ -0,0 +1,243 @@ +// @flow + +/* + * This module contains "Porcelain" for working with Git graphs. By + * "Porcelain", we mean it is a much more convenient and polished API. It + * allows accessing Git graph data via a familiar object-oriented API, + * rather than needing to use the specific graph-based methods in the + * underlying graph. + * + * In general, the porcelain module provides wrapper objects that contain the + * entire Git graph, and a pointer to a particular entity in that graph. + * Creating the wrappers is extremely cheap; all actual computation (e.g. + * finding the body or author of a post) is done lazily when that information + * is requested. + * + * The porcelain system is under active development. + * I expect that we will soon refactor the base porcelain abstraction out of + * this module into core/, and the implementation may shift to be more conserved + * with the GitHub porcelain. The APIs should remain unchanged. + * */ +import stringify from "json-stable-stringify"; + +import {Graph} from "../../core/graph"; +import type {Node} from "../../core/graph"; +import type {Address} from "../../core/address"; + +import type { + TreeEntryNodePayload, + SubmoduleCommitPayload, + BlobNodePayload, + TreeNodePayload, + CommitNodePayload, + NodePayload, + NodeType, + Hash, +} from "./types"; +import {GIT_PLUGIN_NAME} from "./types"; +import {commitAddress} from "./address"; + +export class PorcelainGraph { + graph: Graph; + constructor(graph: Graph) { + this.graph = graph; + } + + // Note that this method is presently unsafe, as the hash may not exist. + // In the future, we will come up with a general case solution to have + // the type system verify that returned porcelains must be existence-tested + // before their properties are usable. + commitByHash(h: Hash): Commit { + const addr = commitAddress(h); + return new Commit(this.graph, addr); + } +} + +export type GitPorcelain = Commit | Blob | Tree | TreeEntry | SubmoduleCommit; + +class BaseGitPorcelain { + graph: Graph; + nodeAddress: Address; + + constructor(graph: Graph, nodeAddress: Address) { + if (nodeAddress.pluginName !== GIT_PLUGIN_NAME) { + throw new Error( + `Tried to create Git porcelain for node from plugin: ${ + nodeAddress.pluginName + }` + ); + } + this.graph = graph; + this.nodeAddress = nodeAddress; + } + + type(): NodeType { + return (this.address().type: any); + } + + node(): Node { + return this.graph.node(this.nodeAddress); + } + + address(): Address { + return this.nodeAddress; + } +} + +export class Commit extends BaseGitPorcelain { + static from(n: BaseGitPorcelain): Commit { + if (n.type() !== "COMMIT") { + throw new Error(`Unable to cast ${n.type()} to Commit`); + } + return new Commit(n.graph, n.nodeAddress); + } + + hash(): Hash { + return this.address().id; + } + + parents(): Commit[] { + return this.graph + .neighborhood(this.nodeAddress, { + nodeType: "COMMIT", + edgeType: "HAS_PARENT", + direction: "OUT", + }) + .map(({neighbor}) => new Commit(this.graph, neighbor)); + } + + tree(): Tree { + const trees = this.graph + .neighborhood(this.nodeAddress, { + nodeType: "TREE", + edgeType: "HAS_TREE", + direction: "OUT", + }) + .map(({neighbor}) => new Tree(this.graph, neighbor)); + if (trees.length !== 1) { + throw new Error( + `Commit ${stringify(this.nodeAddress)} has wrong number of trees` + ); + } + return trees[0]; + } +} + +export class Tree extends BaseGitPorcelain { + static from(n: BaseGitPorcelain): Tree { + if (n.type() !== "TREE") { + throw new Error(`Unable to cast ${n.type()} to Tree`); + } + return new Tree(n.graph, n.nodeAddress); + } + + hash(): Hash { + return this.address().id; + } + + entries(): TreeEntry[] { + return this.graph + .neighborhood(this.nodeAddress, { + nodeType: "TREE_ENTRY", + edgeType: "INCLUDES", + direction: "OUT", + }) + .map(({neighbor}) => new TreeEntry(this.graph, neighbor)); + } + + entry(name: string): ?TreeEntry { + return this.entries().filter((te) => te.name() === name)[0]; + } +} + +export class TreeEntry extends BaseGitPorcelain { + static from(n: BaseGitPorcelain): TreeEntry { + if (n.type() !== "TREE_ENTRY") { + throw new Error(`Unable to cast ${n.type()} to TreeEntry`); + } + return new TreeEntry(n.graph, n.nodeAddress); + } + + name(): string { + return this.node().payload.name; + } + + evolvesTo(): TreeEntry[] { + return this.graph + .neighborhood(this.nodeAddress, { + nodeType: "TREE_ENTRY", + edgeType: "BECOMES", + direction: "OUT", + }) + .map(({neighbor}) => new TreeEntry(this.graph, neighbor)); + } + + evolvesFrom(): TreeEntry[] { + return this.graph + .neighborhood(this.nodeAddress, { + nodeType: "TREE_ENTRY", + edgeType: "BECOMES", + direction: "IN", + }) + .map(({neighbor}) => new TreeEntry(this.graph, neighbor)); + } + + /* + * May be a single Tree, single Blob, or zero or more + * SubmoduleCommits. The Tree or Blob are put in an array for + * consistency. + * + */ + contents(): Tree[] | Blob[] | SubmoduleCommit[] { + // Note: the function has the correct type signature, + // but as-implemented it should be a flow error. + // When flow fixes this, maintain the current method signature. + return this.graph + .neighborhood(this.nodeAddress, { + edgeType: "HAS_CONTENTS", + direction: "OUT", + }) + .map(({neighbor}) => { + switch (neighbor.type) { + case "BLOB": + return new Blob(this.graph, neighbor); + case "TREE": + return new Tree(this.graph, neighbor); + case "SUBMODULE_COMMIT": + return new SubmoduleCommit(this.graph, neighbor); + default: + throw new Error(`Neighbor had invalid type ${neighbor.type}`); + } + }); + } +} + +export class Blob extends BaseGitPorcelain { + static from(n: BaseGitPorcelain): Blob { + if (n.type() !== "BLOB") { + throw new Error(`Unable to cast ${n.type()} to Blob`); + } + return new Blob(n.graph, n.nodeAddress); + } + + hash(): Hash { + return this.nodeAddress.id; + } +} + +export class SubmoduleCommit extends BaseGitPorcelain { + static from(n: BaseGitPorcelain): SubmoduleCommit { + if (n.type() !== "SUBMODULE_COMMIT") { + throw new Error(`Unable to cast ${n.type()} to SubmoduleCommit`); + } + return new SubmoduleCommit(n.graph, n.nodeAddress); + } + + url(): string { + return this.node().payload.url; + } + + hash(): Hash { + return this.node().payload.hash; + } +} diff --git a/src/plugins/git/porcelain.test.js b/src/plugins/git/porcelain.test.js new file mode 100644 index 0000000..98d884f --- /dev/null +++ b/src/plugins/git/porcelain.test.js @@ -0,0 +1,115 @@ +// @flow + +import cloneDeep from "lodash.clonedeep"; +import {PorcelainGraph, Blob, Tree, SubmoduleCommit} from "./porcelain"; +import {createGraph} from "./createGraph"; + +const makePorcelainGraph = () => + new PorcelainGraph(createGraph(cloneDeep(require("./demoData/example-git")))); + +const getCommit = () => { + const graph = makePorcelainGraph(); + const commitHash = "3715ddfb8d4c4fd2a6f6af75488c82f84c92ec2f"; + const commit = graph.commitByHash(commitHash); + if (commit.hash() !== commitHash) { + throw new Error( + `Expected commit hash ${commitHash}, got hash ${commit.hash()}` + ); + } + return commit; +}; +describe("Git porcelain", () => { + it("commits have hashes", () => { + const commit = getCommit(); + const commitHash = "3715ddfb8d4c4fd2a6f6af75488c82f84c92ec2f"; + expect(commit.hash()).toEqual(commitHash); + }); + + it("some commits have parents", () => { + const parentHash = "69c5aad50eec8f2a0a07c988c3b283a6490eb45b"; + const parents = getCommit().parents(); + expect(parents).toHaveLength(1); + expect(parents[0].hash()).toEqual(parentHash); + }); + + it("some commits have no parents", () => { + const commitHash = "c2b51945e7457546912a8ce158ed9d294558d294"; + const commit = makePorcelainGraph().commitByHash(commitHash); + expect(commit.parents()).toEqual([]); + }); + + it("Commits have a unique, hash-identified tree", () => { + const tree = getCommit().tree(); + expect(tree.hash()).toEqual("7be3ecfee5314ffa9b2d93fc4377792b2d6d70ed"); + }); + + it("Trees have tree entries", () => { + const tree = getCommit().tree(); + const entries = tree.entries(); + expect(entries).toHaveLength(5); + const entryNames = entries.map((x) => x.name()); + expect(entryNames).toEqual( + expect.arrayContaining([ + "pygravitydefier", + "src", + ".gitmodules", + "README.txt", + "science.txt", + ]) + ); + }); + it("Tree entries can have blobs", () => { + const entry = getCommit() + .tree() + .entry("science.txt"); + if (entry == null) { + throw new Error("Where is science?!"); + } + const blob: Blob = Blob.from(entry.contents()[0]); + expect(blob.hash()).toEqual("f1f2514ca6d7a6a1a0511957021b1995bf9ace1c"); + }); + + it("Tree entries can have trees", () => { + const entry = getCommit() + .tree() + .entry("src"); + if (entry == null) { + throw new Error("Where is src?!"); + } + const tree: Tree = Tree.from(entry.contents()[0]); + expect(tree.hash()).toEqual("78fc9c83023386854c6bfdc5761c0e58f68e226f"); + }); + + it("Tree entries can have submodule commits", () => { + const entry = getCommit() + .tree() + .entry("pygravitydefier"); + if (entry == null) { + throw new Error("We've stopped defying gravity :("); + } + const sc: SubmoduleCommit = SubmoduleCommit.from(entry.contents()[0]); + expect(sc.hash()).toEqual("29ef158bc982733e2ba429fcf73e2f7562244188"); + expect(sc.url()).toEqual( + "https://github.com/sourcecred/example-git-submodule.git" + ); + }); + + it("Tree entries can evolve to/from other tree entries", () => { + const parentCommitHash = "e8b7a8f19701cd5a25e4a097d513ead60e5f8bcc"; + const childCommitHash = "69c5aad50eec8f2a0a07c988c3b283a6490eb45b"; + const graph = makePorcelainGraph(); + const parentEntry = graph + .commitByHash(parentCommitHash) + .tree() + .entry("src"); + const childEntry = graph + .commitByHash(childCommitHash) + .tree() + .entry("src"); + if (parentEntry == null || childEntry == null) { + throw new Error("Couldn't get expected entries"); + } + expect(parentEntry.evolvesTo()).toEqual([childEntry]); + expect(childEntry.evolvesFrom()).toEqual([parentEntry]); + }); +}); diff --git a/src/plugins/git/types.js b/src/plugins/git/types.js index cffff81..028a861 100644 --- a/src/plugins/git/types.js +++ b/src/plugins/git/types.js @@ -113,6 +113,11 @@ export function includesEdgeId(treeSha: string, name: string): string { } // TreeEntryNode -> TreeEntryNode +// TODO: Rename the BECOMES edges to EVOLVES, as then we can cleanly express +// the bidrectional relationship: EvolvesTo and EvolvesFrom. Note that doing +// so is a breaking change, and thus this change should be made after we have a +// versioning system that can either maintain backcompat or invalidate old +// serializations. See #280 export const BECOMES_EDGE_TYPE: "BECOMES" = "BECOMES"; export type BecomesEdgePayload = {| +childCommit: Hash,