diff --git a/index.html b/index.html
index 87e93c643..07063cf81 100644
--- a/index.html
+++ b/index.html
@@ -81,6 +81,7 @@
+
diff --git a/js/id/actions/join.js b/js/id/actions/join.js
new file mode 100644
index 000000000..fd6862a9b
--- /dev/null
+++ b/js/id/actions/join.js
@@ -0,0 +1,65 @@
+// Join ways at the end node they share.
+//
+// This is the inverse of `iD.actions.Split`.
+//
+// Reference:
+// https://github.com/systemed/potlatch2/blob/master/net/systemeD/halcyon/connection/actions/MergeWaysAction.as
+// https://github.com/openstreetmap/josm/blob/mirror/src/org/openstreetmap/josm/actions/CombineWayAction.java
+//
+iD.actions.Join = function(idA, idB) {
+ var action = function(graph) {
+ var a = graph.entity(idA),
+ b = graph.entity(idB),
+ nodes, tags;
+
+ if (a.first() === b.first()) {
+ // a <-- b ==> c
+ // Expected result:
+ // a <-- b <-- c
+ nodes = b.nodes.slice().reverse().concat(a.nodes.slice(1));
+
+ } else if (a.first() === b.last()) {
+ // a <-- b <== c
+ // Expected result:
+ // a <-- b <-- c
+ nodes = b.nodes.concat(a.nodes.slice(1));
+
+ } else if (a.last() === b.first()) {
+ // a --> b ==> c
+ // Expected result:
+ // a --> b --> c
+ nodes = a.nodes.concat(b.nodes.slice(1));
+
+ } else if (a.last() === b.last()) {
+ // a --> b <== c
+ // Expected result:
+ // a --> b --> c
+ nodes = a.nodes.concat(b.nodes.slice().reverse().slice(1));
+ }
+
+ graph.parentRelations(b)
+ .forEach(function (parent) {
+ var memberA = parent.memberById(idA),
+ memberB = parent.memberById(idB);
+ if (!memberA) {
+ graph = graph.replace(parent.addMember({id: idA, role: memberB.role}));
+ }
+ });
+
+ graph = graph.replace(a.mergeTags(b.tags).update({nodes: nodes}));
+ graph = iD.actions.DeleteWay(idB)(graph);
+
+ return graph;
+ };
+
+ action.enabled = function(graph) {
+ var a = graph.entity(idA),
+ b = graph.entity(idB);
+ return a.first() === b.first() ||
+ a.first() === b.last() ||
+ a.last() === b.first() ||
+ a.last() === b.last();
+ };
+
+ return action;
+};
diff --git a/js/id/actions/split.js b/js/id/actions/split.js
index 0c648bd26..19a71c619 100644
--- a/js/id/actions/split.js
+++ b/js/id/actions/split.js
@@ -1,5 +1,7 @@
// Split a way at the given node.
//
+// This is the inverse of `iD.actions.Join`.
+//
// For testing convenience, accepts an ID to assign to the new way.
// Normally, this will be undefined and the way will automatically
// be assigned a new ID.
diff --git a/test/index.html b/test/index.html
index 512a1ee1e..524894c36 100644
--- a/test/index.html
+++ b/test/index.html
@@ -78,6 +78,7 @@
+
@@ -147,6 +148,7 @@
+
diff --git a/test/index_packaged.html b/test/index_packaged.html
index 5adaf06f6..948ed0ae1 100644
--- a/test/index_packaged.html
+++ b/test/index_packaged.html
@@ -40,6 +40,7 @@
+
diff --git a/test/spec/actions/join.js b/test/spec/actions/join.js
new file mode 100644
index 000000000..1cddb94bf
--- /dev/null
+++ b/test/spec/actions/join.js
@@ -0,0 +1,172 @@
+describe("iD.actions.Join", function () {
+ describe("#enabled", function () {
+ it("returns true for ways that share an end/start node", function () {
+ // a --> b ==> c
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b']}),
+ '=': iD.Way({id: '=', nodes: ['b', 'c']})
+ });
+
+ expect(iD.actions.Join('-', '=').enabled(graph)).to.be.true;
+ });
+
+ it("returns true for ways that share a start/end node", function () {
+ // a <-- b <== c
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['b', 'a']}),
+ '=': iD.Way({id: '=', nodes: ['c', 'b']})
+ });
+
+ expect(iD.actions.Join('-', '=').enabled(graph)).to.be.true;
+ });
+
+ it("returns true for ways that share a start/start node", function () {
+ // a <-- b ==> c
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['b', 'a']}),
+ '=': iD.Way({id: '=', nodes: ['b', 'c']})
+ });
+
+ expect(iD.actions.Join('-', '=').enabled(graph)).to.be.true;
+ });
+
+ it("returns true for ways that share an end/end node", function () {
+ // a --> b <== c
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b']}),
+ '=': iD.Way({id: '=', nodes: ['c', 'b']})
+ });
+
+ expect(iD.actions.Join('-', '=').enabled(graph)).to.be.true;
+ });
+
+ it("returns false for ways that don't share the necessary nodes", function () {
+ // a -- b -- c
+ // |
+ // d
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ 'd': iD.Node({id: 'd'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}),
+ '=': iD.Way({id: '=', nodes: ['b', 'd']})
+ });
+
+ expect(iD.actions.Join('-', '=').enabled(graph)).to.be.false;
+ });
+ });
+
+ it("joins a --> b ==> c", function () {
+ // Expected result:
+ // a --> b --> c
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b']}),
+ '=': iD.Way({id: '=', nodes: ['b', 'c']})
+ });
+
+ graph = iD.actions.Join('-', '=')(graph);
+
+ expect(graph.entity('-').nodes).to.eql(['a', 'b', 'c']);
+ expect(graph.entity('=')).to.be.undefined;
+ });
+
+ it("joins a <-- b <== c", function () {
+ // Expected result:
+ // a <-- b <-- c
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['b', 'a']}),
+ '=': iD.Way({id: '=', nodes: ['c', 'b']})
+ });
+
+ graph = iD.actions.Join('-', '=')(graph);
+
+ expect(graph.entity('-').nodes).to.eql(['c', 'b', 'a']);
+ expect(graph.entity('=')).to.be.undefined;
+ });
+
+ it("joins a <-- b ==> c", function () {
+ // Expected result:
+ // a <-- b <-- c
+ // tags on === reversed
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['b', 'a']}),
+ '=': iD.Way({id: '=', nodes: ['b', 'c']})
+ });
+
+ graph = iD.actions.Join('-', '=')(graph);
+
+ expect(graph.entity('-').nodes).to.eql(['c', 'b', 'a']);
+ expect(graph.entity('=')).to.be.undefined;
+ });
+
+ it("joins a --> b <== c", function () {
+ // Expected result:
+ // a --> b --> c
+ // tags on === reversed
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b']}),
+ '=': iD.Way({id: '=', nodes: ['c', 'b']})
+ });
+
+ graph = iD.actions.Join('-', '=')(graph);
+
+ expect(graph.entity('-').nodes).to.eql(['a', 'b', 'c']);
+ expect(graph.entity('=')).to.be.undefined;
+ });
+
+ it("merges tags", function () {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b'], tags: {a: 'a', b: '-', c: 'c'}}),
+ '=': iD.Way({id: '=', nodes: ['b', 'c'], tags: {a: 'a', b: '=', d: 'd'}})
+ });
+
+ graph = iD.actions.Join('-', '=')(graph);
+
+ expect(graph.entity('-').tags).to.eql({a: 'a', b: '-; =', c: 'c', d: 'd'});
+ });
+
+ it("merges relations", function () {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b']}),
+ '=': iD.Way({id: '=', nodes: ['b', 'c']}),
+ 'r1': iD.Relation({id: 'r1', members: [{id: '=', role: 'r1'}]}),
+ 'r2': iD.Relation({id: 'r2', members: [{id: '=', role: 'r1'}, {id: '-', role: 'r2'}]})
+ });
+
+ graph = iD.actions.Join('-', '=')(graph);
+
+ expect(graph.entity('r1').members).to.eql([{id: '-', role: 'r1'}]);
+ expect(graph.entity('r2').members).to.eql([{id: '-', role: 'r2'}]);
+ });
+});