Add porcelain for the Git plugin (#281)

This commit adds "Node Porcelain" for the Git plugin. Node porcelain is
a wrapper over a graph and node address, which makes it possible to
access payload data and adjacent nodes via a familiar object-oriented
API.

I believe this porcelain provides substantially better legibility and
usability for the Git plugin. Consider that it is now easy to see what
relationships each Git node type can have by reading the method
signatures in the porcelain, rather than needing to inspect all of the
Edge types in types.js.

This porcelain has slightly different conventions from the porcelain in
the GitHub plugin, although the APIs are very similar. I intend to
follow this commit with two more: one that switches clients of the Git
plugin to use the porcelain, and another that refactors the GitHub and
Git porcelains to use a base Porcelain implementation in src/core.

Test plan:
Examine the public API of the Git porcelain (this is unlikely to change
much), and its corresponding test code.

`yarn travis --full` passes.
This commit is contained in:
Dandelion Mané 2018-05-14 12:46:20 -07:00 committed by GitHub
parent 115d7f3921
commit fb8da7fcdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 363 additions and 0 deletions

View File

@ -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<any, any>;
constructor(graph: Graph<any, any>) {
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<T: NodePayload> {
graph: Graph<any, any>;
nodeAddress: Address;
constructor(graph: Graph<any, any>, 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<T> {
return this.graph.node(this.nodeAddress);
}
address(): Address {
return this.nodeAddress;
}
}
export class Commit extends BaseGitPorcelain<CommitNodePayload> {
static from(n: BaseGitPorcelain<any>): 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<TreeNodePayload> {
static from(n: BaseGitPorcelain<any>): 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<TreeEntryNodePayload> {
static from(n: BaseGitPorcelain<any>): 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<BlobNodePayload> {
static from(n: BaseGitPorcelain<any>): 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<SubmoduleCommitPayload> {
static from(n: BaseGitPorcelain<any>): 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;
}
}

View File

@ -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]);
});
});

View File

@ -113,6 +113,11 @@ export function includesEdgeId(treeSha: string, name: string): string {
} }
// TreeEntryNode -> TreeEntryNode // 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 const BECOMES_EDGE_TYPE: "BECOMES" = "BECOMES";
export type BecomesEdgePayload = {| export type BecomesEdgePayload = {|
+childCommit: Hash, +childCommit: Hash,