-
Notifications
You must be signed in to change notification settings - Fork 108
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3201 from opral/branching
Branching
- Loading branch information
Showing
26 changed files
with
1,465 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { test, expect } from "vitest"; | ||
import { updateBranchPointers } from "../branch/update-branch-pointers.js"; | ||
import { openLixInMemory } from "../open/openLixInMemory.js"; | ||
import { createBranch } from "./create-branch.js"; | ||
|
||
test("it should copy the change pointers from the parent branch", async () => { | ||
const lix = await openLixInMemory({}); | ||
|
||
const mainBranch = await lix.db | ||
.selectFrom("branch") | ||
.selectAll() | ||
.where("name", "=", "main") | ||
.executeTakeFirstOrThrow(); | ||
|
||
await lix.db.transaction().execute(async (trx) => { | ||
const changes = await trx | ||
.insertInto("change") | ||
.values([ | ||
{ | ||
type: "file", | ||
entity_id: "value1", | ||
file_id: "mock", | ||
plugin_key: "mock-plugin", | ||
snapshot_id: "sn1", | ||
}, | ||
{ | ||
type: "file", | ||
entity_id: "value2", | ||
file_id: "mock", | ||
plugin_key: "mock-plugin", | ||
snapshot_id: "sn2", | ||
}, | ||
]) | ||
.returningAll() | ||
.execute(); | ||
|
||
await updateBranchPointers({ | ||
lix: { db: trx }, | ||
branch: mainBranch, | ||
changes, | ||
}); | ||
}); | ||
|
||
const branch = await createBranch({ | ||
lix, | ||
from: mainBranch, | ||
name: "feature-branch", | ||
}); | ||
|
||
const branchChangePointers = await lix.db | ||
.selectFrom("branch_change_pointer") | ||
.selectAll() | ||
.execute(); | ||
|
||
// main and feature branch should have the same change pointers | ||
expect(branchChangePointers.length).toBe(4); | ||
expect(branchChangePointers.map((pointer) => pointer.branch_id)).toEqual([ | ||
mainBranch.id, | ||
mainBranch.id, | ||
branch.id, | ||
branch.id, | ||
]); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import type { Branch } from "../database/schema.js"; | ||
import type { Lix } from "../open/openLix.js"; | ||
|
||
/** | ||
* Creates a new branch. | ||
* | ||
* @example | ||
* ```ts | ||
* const branch = await createBranch({ lix, from: otherBranch }); | ||
* ``` | ||
*/ | ||
export async function createBranch(args: { | ||
lix: Pick<Lix, "db">; | ||
from: Pick<Branch, "id">; | ||
name?: Branch["name"]; | ||
}): Promise<Branch> { | ||
const executeInTransaction = async (trx: Lix["db"]) => { | ||
const branch = await trx | ||
.insertInto("branch") | ||
.defaultValues() | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
if (args.name) { | ||
await trx | ||
.updateTable("branch") | ||
.set({ name: args.name }) | ||
.where("id", "=", branch.id) | ||
.execute(); | ||
} | ||
|
||
// copy the change pointers from the parent branch | ||
await trx | ||
.insertInto("branch_change_pointer") | ||
.columns([ | ||
"branch_id", | ||
"change_id", | ||
"change_file_id", | ||
"change_entity_id", | ||
"change_type", | ||
]) | ||
.expression((eb) => | ||
trx | ||
.selectFrom("branch_change_pointer") | ||
.select([ | ||
eb.val(branch.id).as("branch_id"), | ||
"change_id", | ||
"change_file_id", | ||
"change_entity_id", | ||
"change_type", | ||
]) | ||
.where("branch_id", "=", args.from.id), | ||
) | ||
.execute(); | ||
|
||
return branch; | ||
}; | ||
|
||
if (args.lix.db.isTransaction) { | ||
return executeInTransaction(args.lix.db); | ||
} else { | ||
return args.lix.db.transaction().execute(executeInTransaction); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
/** | ||
* Public API for the branches. | ||
*/ | ||
|
||
export { createBranch } from "./create-branch.js"; | ||
export { switchBranch } from "./switch-branch.js"; | ||
export { updateBranchPointers } from "./update-branch-pointers.js"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,254 @@ | ||
import { test, expect, vi } from "vitest"; | ||
import { openLixInMemory } from "../open/openLixInMemory.js"; | ||
import type { LixPlugin } from "../plugin/lix-plugin.js"; | ||
import { mergeBranch } from "./merge-branch.js"; | ||
|
||
test("it should update the branch pointers for non-conflicting changes and insert detected conflicts", async () => { | ||
const lix = await openLixInMemory({}); | ||
|
||
// Initialize source and target branches | ||
const sourceBranch = await lix.db | ||
.insertInto("branch") | ||
.values({ name: "source-branch" }) | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
const targetBranch = await lix.db | ||
.insertInto("branch") | ||
.values({ name: "target-branch" }) | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
// Insert changes into `change` table and `branch_change_pointer` for source branch | ||
const [change1, change2, change3] = await lix.db | ||
.insertInto("change") | ||
.values([ | ||
{ | ||
id: "change1", | ||
type: "file", | ||
entity_id: "entity1", | ||
file_id: "file1", | ||
plugin_key: "mock-plugin", | ||
snapshot_id: "no-content", | ||
}, | ||
{ | ||
id: "change2", | ||
type: "file", | ||
entity_id: "entity2", | ||
file_id: "file2", | ||
plugin_key: "mock-plugin", | ||
snapshot_id: "no-content", | ||
}, | ||
{ | ||
id: "change3", | ||
type: "file", | ||
entity_id: "entity3", | ||
file_id: "file3", | ||
plugin_key: "mock-plugin", | ||
snapshot_id: "no-content", | ||
}, | ||
]) | ||
.returningAll() | ||
.execute(); | ||
|
||
await lix.db | ||
.insertInto("branch_change_pointer") | ||
.values([ | ||
{ | ||
branch_id: sourceBranch.id, | ||
change_id: change1!.id, | ||
change_entity_id: change1!.entity_id, | ||
change_file_id: change1!.file_id, | ||
change_type: change1!.type, | ||
}, | ||
{ | ||
branch_id: sourceBranch.id, | ||
change_id: change2!.id, | ||
change_entity_id: change2!.entity_id, | ||
change_file_id: change2!.file_id, | ||
change_type: change2!.type, | ||
}, | ||
{ | ||
branch_id: sourceBranch.id, | ||
change_id: change3!.id, | ||
change_entity_id: change3!.entity_id, | ||
change_file_id: change3!.file_id, | ||
change_type: change3!.type, | ||
}, | ||
]) | ||
.execute(); | ||
|
||
const mockPlugin: LixPlugin = { | ||
key: "mock", | ||
detectConflictsV2: async () => { | ||
// simulating a conflict between change2 and change3 | ||
return [ | ||
{ | ||
change_id: change2!.id, | ||
conflicting_change_id: change3!.id, | ||
}, | ||
]; | ||
}, | ||
}; | ||
|
||
lix.plugin.getAll = vi.fn().mockResolvedValue([mockPlugin]); | ||
|
||
// Execute the mergeBranch function | ||
await mergeBranch({ lix, sourceBranch, targetBranch }); | ||
|
||
// Validate results in `branch_change_pointer` and `conflict` tables | ||
const targetPointers = await lix.db | ||
.selectFrom("branch_change_pointer") | ||
.selectAll() | ||
.where("branch_id", "=", targetBranch.id) | ||
.execute(); | ||
|
||
const conflicts = await lix.db.selectFrom("conflict").selectAll().execute(); | ||
|
||
// Ensure that non-conflicting pointers (change1 and change2) are in target branch | ||
expect(targetPointers.map((pointer) => pointer.change_id)).toContain( | ||
change1?.id, | ||
); | ||
expect(targetPointers.map((pointer) => pointer.change_id)).toContain( | ||
change2?.id, | ||
); | ||
|
||
// Ensure that conflicting pointer (change3) is not in target branch | ||
expect(targetPointers.map((pointer) => pointer.change_id)).not.toContain( | ||
change3?.id, | ||
); | ||
|
||
// Verify that a conflict for change2 was added to the `conflict` table | ||
expect(conflicts.map((conflict) => conflict.change_id)).toContain( | ||
change2?.id, | ||
); | ||
}); | ||
|
||
// it is reasonable to assume that a conflict exists if the same (entity, file, type) change is updated in both branches. | ||
// in case a plugin does not detect a conflict, the system should automatically detect it. | ||
test("it should automatically detect a conflict if a change exists that differs updates in both branches despite having a common ancestor", async () => { | ||
const lix = await openLixInMemory({}); | ||
|
||
// Initialize source and target branches | ||
const sourceBranch = await lix.db | ||
.insertInto("branch") | ||
.values({ name: "source-branch" }) | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
const targetBranch = await lix.db | ||
.insertInto("branch") | ||
.values({ name: "target-branch" }) | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
const ancestorChange = await lix.db | ||
.insertInto("change") | ||
.values({ | ||
id: "ancestor-change", | ||
type: "type1", | ||
entity_id: "entity1", | ||
file_id: "file1", | ||
plugin_key: "mock", | ||
snapshot_id: "no-content", | ||
}) | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
// Simulate updates to the entity in both branches | ||
const sourceChange = await lix.db | ||
.insertInto("change") | ||
.values({ | ||
id: "source-change", | ||
type: "type1", | ||
entity_id: "entity1", | ||
file_id: "file1", | ||
plugin_key: "mock", | ||
snapshot_id: "no-content", | ||
}) | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
const targetChange = await lix.db | ||
.insertInto("change") | ||
.values({ | ||
id: "target-change", | ||
type: "type1", | ||
entity_id: "entity1", | ||
file_id: "file1", | ||
plugin_key: "mock", | ||
snapshot_id: "no-content", | ||
}) | ||
.returningAll() | ||
.executeTakeFirstOrThrow(); | ||
|
||
// insert edges to ancestor change | ||
await lix.db | ||
.insertInto("change_graph_edge") | ||
.values([ | ||
{ parent_id: ancestorChange.id, child_id: sourceChange.id }, | ||
{ parent_id: ancestorChange.id, child_id: targetChange.id }, | ||
]) | ||
.execute(); | ||
|
||
// Insert head pointers for source and target branches | ||
await lix.db | ||
.insertInto("branch_change_pointer") | ||
.values([ | ||
{ | ||
branch_id: sourceBranch.id, | ||
change_id: sourceChange.id, | ||
change_entity_id: "entity1", | ||
change_file_id: "file1", | ||
change_type: "type1", | ||
}, | ||
{ | ||
branch_id: targetBranch.id, | ||
change_id: targetChange.id, | ||
change_entity_id: "entity1", | ||
change_file_id: "file1", | ||
change_type: "type1", | ||
}, | ||
]) | ||
.execute(); | ||
|
||
const mockPlugin: LixPlugin = { | ||
key: "mock-plugin", | ||
detectConflictsV2: async () => { | ||
// Simulate no manual conflicts; system should detect automatically | ||
return []; | ||
}, | ||
}; | ||
lix.plugin.getAll = vi.fn().mockResolvedValue([mockPlugin]); | ||
|
||
await mergeBranch({ lix, sourceBranch, targetBranch }); | ||
|
||
// Validate results in `conflict` table | ||
const conflicts = await lix.db | ||
.selectFrom("conflict") | ||
.selectAll() | ||
.where("change_id", "=", sourceChange.id) | ||
.execute(); | ||
|
||
// Ensure that the change from `sourceBranch` is detected as a conflict | ||
expect(conflicts).toEqual([ | ||
expect.objectContaining({ | ||
change_id: sourceChange.id, | ||
conflicting_change_id: targetChange.id, | ||
}), | ||
]); | ||
|
||
// ensure that the branch change pointer hasn't been updated | ||
const targetPointers = await lix.db | ||
.selectFrom("branch_change_pointer") | ||
.selectAll() | ||
.where("branch_id", "=", targetBranch.id) | ||
.execute(); | ||
|
||
expect(targetPointers.map((pointer) => pointer.change_id)).not.toContain( | ||
sourceChange.id, | ||
); | ||
expect(targetPointers.map((pointer) => pointer.change_id)).toContain( | ||
targetChange.id, | ||
); | ||
}); |
Oops, something went wrong.