feat: add remote branch diff and merge client APIs (#3686)

This PR adds some support for `diff` / `merge` in the remote client as
for local tables we stay `NotSupported` until
https://github.com/lance-format/lance/issues/7263.


This wires the two review-and-land calls against the remote REST API:
- `POST /v1/table/{id}/branches/diff`
- `POST /v1/table/{id}/branches/merge`

Rust gets typed results (`BranchDiff`, `MergeBranchResult`). Python
returns the wire JSON, same shape as the REST response.

Merge here means promoting a branch's added columns onto `main`.

### Behavior
- Remote only. Local raises `NotSupported`.
- A rejected merge is not an exception. HTTP 409 still returns `Ok` / a
dict with `status="rejected"` and blockers in `diff.mergeBlockers`.
- Unknown blocker / status codes parse as `Unknown` so a newer server
does not break older clients.
- `MergePreview` tolerates missing fields for the same reason.
- Merge requests are not retried. 409 is final and carries the body you
need.

### Example
```python

table = db.open_table("images")

table.branches.create("exp")
exp = table.branches.checkout("exp")

exp.add_columns({"tag": "cast('draft' as string)"})

diff = table.branches.diff("exp")
preview = table.branches.merge("exp", dry_run=True)
result = table.branches.merge("exp", dry_run=False)

if result["status"] == "merged":
    print("landed at", result["mainVersionAfter"])
elif result["status"] == "rejected":
    print(result["diff"]["mergeBlockers"])
```

### Testing
cargo test -p lancedb --features remote diff_branch
cargo test -p lancedb --features remote merge_branch

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Drew Gallardo
2026-07-18 12:38:05 -07:00
committed by GitHub
parent 5d0a1ef66c
commit 65cd142c7e
21 changed files with 1156 additions and 0 deletions
+4
View File
@@ -319,6 +319,10 @@ class Branches:
) -> Table: ...
async def checkout(self, name: str, version: Optional[int] = None) -> Table: ...
async def delete(self, name: str) -> None: ...
async def diff(self, from_branch: str) -> Dict[str, Any]: ...
async def merge(
self, from_branch: str, dry_run: bool = False
) -> Dict[str, Any]: ...
class IndexConfig:
name: str
+29
View File
@@ -6267,6 +6267,24 @@ class Branches:
"""Delete a branch."""
LOOP.run(self._table.branches.delete(name))
def diff(self, from_branch: str) -> Dict[str, Any]:
"""Diff a branch against main."""
return LOOP.run(self._table.branches.diff(from_branch))
def merge(self, from_branch: str, dry_run: bool = False) -> Dict[str, Any]:
"""Merge a branch into main, or dry-run.
Parameters
----------
from_branch: str
Branch to merge from.
dry_run: bool, default False
When True, only preview. When False, attempt the merge.
A rejected merge returns ``status="rejected"`` instead of raising.
"""
return LOOP.run(self._table.branches.merge(from_branch, dry_run))
def _wrap(
self, async_table: "AsyncTable", version: Optional[int] = None
) -> "Table":
@@ -6396,3 +6414,14 @@ class AsyncBranches:
async def delete(self, name: str) -> None:
"""Delete a branch."""
await self._table.branches.delete(name)
async def diff(self, from_branch: str) -> Dict[str, Any]:
"""Diff a branch against main."""
return await self._table.branches.diff(from_branch)
async def merge(self, from_branch: str, dry_run: bool = False) -> Dict[str, Any]:
"""Merge a branch into main, or dry-run.
A rejected merge returns ``status="rejected"`` instead of raising.
"""
return await self._table.branches.merge(from_branch, dry_run)
+59
View File
@@ -236,6 +236,65 @@ def test_remote_table_branches_sync():
table.branches.delete("exp")
def test_remote_table_branch_merge_defaults_to_execute():
merge_bodies = []
diff = {
"fromBranch": "exp",
"parentVersion": 1,
"mainVersion": 2,
"branchVersion": 3,
"baseMoved": False,
"rowCountMain": 3,
"rowCountBranch": 3,
"rowSummary": {
"unchanged": 3,
"newOnBase": 0,
"newOnBranch": 0,
"staleRecompute": 0,
"inputsChanged": 0,
"deltaAvailable": False,
},
"addedColumns": [],
"removedColumns": [],
"changedColumns": [],
"addedIndexes": [],
"removedIndexes": [],
"mergeable": True,
"mergeBlockers": [],
}
def handler(request):
if request.path.endswith("/describe/"):
status = 200
body = {"version": 2, "schema": {"fields": []}}
else:
content_len = int(request.headers.get("Content-Length"))
request_body = json.loads(request.rfile.read(content_len))
merge_bodies.append(request_body)
dry_run = request_body["dry_run"]
status = 200 if dry_run else 409
body = {
"status": "ready" if dry_run else "rejected",
"diff": diff,
"preview": {"promotedColumns": []},
}
request.send_response(status)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(json.dumps(body).encode())
with mock_lancedb_connection(handler) as db:
branches = db.open_table("test").branches
assert branches.merge("exp")["status"] == "rejected"
assert branches.merge("exp", dry_run=True)["status"] == "ready"
assert merge_bodies == [
{"from_branch": "exp", "dry_run": False},
{"from_branch": "exp", "dry_run": True},
]
@pytest.mark.asyncio
async def test_async_remote_open_table_branch_and_version():
async with mock_lancedb_connection_async(_branch_open_handler) as db:
+36
View File
@@ -1593,4 +1593,40 @@ impl Branches {
Ok(())
})
}
pub fn diff(self_: PyRef<'_, Self>, from_branch: String) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner.clone();
future_into_py(self_.py(), async move {
let diff = inner.diff_branch(&from_branch).await.infer_error()?;
Python::attach(|py| struct_to_wire_py(py, &diff))
})
}
#[pyo3(signature = (from_branch, dry_run=false))]
pub fn merge(
self_: PyRef<'_, Self>,
from_branch: String,
dry_run: bool,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner.clone();
future_into_py(self_.py(), async move {
let result = inner
.merge_branch(&from_branch, dry_run)
.await
.infer_error()?;
Python::attach(|py| struct_to_wire_py(py, &result))
})
}
}
/// Decode a serde value as the wire JSON object (camelCase keys).
fn struct_to_wire_py(py: Python<'_>, value: &impl serde::Serialize) -> PyResult<Py<PyAny>> {
let json = py.import("json")?;
Ok(json
.call_method1(
"loads",
(serde_json::to_string(value)
.map_err(|e| PyRuntimeError::new_err(format!("failed to serialize json: {e}")))?,),
)?
.unbind())
}