From 75bae9e94dde4f2eb1fe4639735aaf29c789eb23 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 25 Sep 2026 06:02:12 +0000 Subject: [PATCH] Share tree merging through the server --- SPEC.md | 36 +--- rust/crates/caos/src/bin/caos.rs | 3 +- rust/crates/caos/src/git_objects.rs | 69 +++++++ rust/crates/caos/src/lib.rs | 1 + rust/crates/server/src/git_objects.rs | 274 ++++++++++++++++++++++++++ rust/crates/server/src/main.rs | 2 + std/git-commit-tree/.caos-expr | 11 ++ std/git-commit-tree/DEPS | 1 + std/git-commit-tree/worker.sh | 31 +++ std/git-merge-tree/.caos-expr | 9 + std/git-merge-tree/DEPS | 1 + std/git-merge-tree/worker.sh | 8 + std/merge/flake.nix | 29 +-- std/merge/worker | 93 ++++----- tests/merge/worker.sh | 3 +- 15 files changed, 460 insertions(+), 111 deletions(-) create mode 100644 rust/crates/caos/src/git_objects.rs create mode 100644 rust/crates/server/src/git_objects.rs create mode 100644 std/git-commit-tree/.caos-expr create mode 100644 std/git-commit-tree/DEPS create mode 100755 std/git-commit-tree/worker.sh create mode 100644 std/git-merge-tree/.caos-expr create mode 100644 std/git-merge-tree/DEPS create mode 100755 std/git-merge-tree/worker.sh diff --git a/SPEC.md b/SPEC.md index 2cf9c2912..413dab053 100644 --- a/SPEC.md +++ b/SPEC.md @@ -728,33 +728,15 @@ limits of commit-based fetch negotiation. ## `merge --theirs=` -- Takes exactly one commit arg (`theirs`). The other side (`ours`) is the - selected source tree commit at dispatch, including earlier accepted edits. -- The merge is index-free and worktree-free: `git merge-tree --write-tree - ` is a pure `(commit, commit) -> (tree, conflict report)`, - which memoizes like any other job and needs no materialized working copy - (the harness forbids one). The merge base is `merge-base(ours, theirs)`, - which `merge-tree` derives from the commit graph. -- It runs the real `git` binary in its own worker. `.caos/conflicts` - and the inline markers are straight from git's own output (below), so we want - `merge-tree`'s exact notation, not a reimplementation. gix is a dependency - but carries no merge (`gix-merge` is not pulled in), and its output would not - match git's notation anyway. So `merge` is a decomposed compute tool like - `bash`/`build`/`test`; the file tools (`read`/`ls`/`write`/`edit`) are in-process. -- Its image is a small git worker — a `std/merge` flake - (`nixpkgs.gitMinimal`) run as `curry(std/runner, worker1=)`, - the same flake-image pattern as `std/bash`. Not `std/cargo` (which has git - but is a heavy image and the wrong home) and not folded into the bash-tool - image (whose surface stays minimal). The script reconstructs a git odb from - the `ours`/`theirs` commit closures in `/cas` (a merge is inherently a - both-whole-trees op — the one place laziness can't help), runs `merge-tree`, - writes `.caos/conflicts`, and `put-commit`s the two-parent commit as its - result. -- Clean merge → `M`'s tree is the merged source tree and the merge is done. -- Conflicts → `M`'s tree carries inline conflict markers in the text files - (what the agent edits), plus a reserved `.caos/conflicts` file (below). The - agent resolves over subsequent turns; each resolution is an ordinary - mutation commit on top of `M`. +The source merge worker passes the selected source commit and `theirs` to +`POST /git/merge-tree`. With two commits the endpoint lets Git find their merge +base; with an explicit base it accepts three tree ids. Both use the same +`git merge-tree --write-tree` implementation in the server's existing bare +repository, returning the merged tree and native conflict report. + +The source merge worker creates the two-parent result commit. On conflict it +adds Git's report at `.caos/conflicts`; a clean merge keeps the returned tree. +It reads individual objects and does not clone or check out the source tree. ## Resolving `--theirs` diff --git a/rust/crates/caos/src/bin/caos.rs b/rust/crates/caos/src/bin/caos.rs index 954f0798f..28d2bbd41 100644 --- a/rust/crates/caos/src/bin/caos.rs +++ b/rust/crates/caos/src/bin/caos.rs @@ -49,6 +49,7 @@ fn main() -> ExitCode { fn run(args: &[String]) -> Result<(), String> { match args.get(1).map(String::as_str) { + Some("git-merge-tree") => caos::git_objects::run_merge(&args[2..]), Some("import-git") => caos::import_git::run(&args[2..]), Some("push-git") => caos::push_git::run(&args[2..]), Some("get-hash") => match (args.get(2), args.get(3), args.get(4)) { @@ -521,7 +522,7 @@ fn wipe_dir_contents(dir: &str) { fn usage(args: &[String]) -> String { let prog = prog_name(args); format!( - "usage:\n {prog} import-git [--github-token-file=]\n {prog} push-git --expected= [--github-token-file=]\n {prog} resolve \n {prog} kind \n {prog} get-hash \n \ + "usage:\n {prog} git-merge-tree \n {prog} import-git [--github-token-file=]\n {prog} push-git --expected= [--github-token-file=]\n {prog} resolve \n {prog} kind \n {prog} get-hash \n \ {prog} get [-r | --recursive[=]] \n \ {prog} put \n \ {prog} put-commit \n \ diff --git a/rust/crates/caos/src/git_objects.rs b/rust/crates/caos/src/git_objects.rs new file mode 100644 index 000000000..836ca92b5 --- /dev/null +++ b/rust/crates/caos/src/git_objects.rs @@ -0,0 +1,69 @@ +//! Git primitives whose inputs and outputs are object IDs, never checkouts. +use crate::{ServerRequest, Transport}; + +#[derive(Debug, PartialEq, Eq)] +pub struct MergeTree { + pub tree: String, + /// Native merge-tree output after its leading tree ID, including stages. + pub conflicts: String, +} + +pub fn merge_tree( + transport: &dyn Transport, + merge_base: Option<&str>, + ours: &str, + theirs: &str, +) -> Result { + for hash in merge_base.into_iter().chain([ours, theirs]) { + oid(hash)?; + transport.ensure_pushed(hash)?; + } + let request = serde_json::json!({ + "merge_base": merge_base, "ours": ours, "theirs": theirs, + }) + .to_string(); + let bytes = crate::server_call( + &transport.server_url()?, + &ServerRequest { + method: "POST", + path: "/git/merge-tree", + headers: &[("Content-Type", "application/json".into())], + body: Some(request.as_bytes()), + timeout_secs: None, + }, + )?; + let result: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| format!("invalid merge-tree response: {e}"))?; + let tree = result["tree"] + .as_str() + .ok_or("merge-tree response lacks tree")?; + oid(tree)?; + let conflicts = result["conflicts"] + .as_str() + .ok_or("merge-tree response lacks conflicts")?; + Ok(MergeTree { + tree: tree.to_owned(), + conflicts: conflicts.to_owned(), + }) +} + +fn oid(value: &str) -> Result { + if !git_locator::import::commit(value) { + return Err("expected a full object hash".into()); + } + Ok(value.to_ascii_lowercase()) +} + +pub fn run_merge(args: &[String]) -> Result<(), String> { + let (base, ours, theirs) = match args { + [base, ours, theirs] => (Some(base.as_str()), ours, theirs), + [ours, theirs] => (None, ours, theirs), + _ => return Err("git-merge-tree requires , or ".into()), + }; + let result = merge_tree(&crate::HttpTransport::from_env()?, base, ours, theirs)?; + println!( + "{}", + serde_json::json!({"tree":result.tree, "conflicts":result.conflicts}) + ); + Ok(()) +} diff --git a/rust/crates/caos/src/lib.rs b/rust/crates/caos/src/lib.rs index bc756054d..2b2cb9ebf 100644 --- a/rust/crates/caos/src/lib.rs +++ b/rust/crates/caos/src/lib.rs @@ -18,6 +18,7 @@ //! per-path, thread-safe mapping from CAS paths back to hashes, and what lets //! `get` expand a placeholder later. +pub mod git_objects; pub mod gitlinks; pub mod import_git; pub mod push_git; diff --git a/rust/crates/server/src/git_objects.rs b/rust/crates/server/src/git_objects.rs new file mode 100644 index 000000000..7c8693cd3 --- /dev/null +++ b/rust/crates/server/src/git_objects.rs @@ -0,0 +1,274 @@ +//! Worktree-free Git operations over objects already stored on the server. +use crate::{Config, HttpError}; +use serde::Deserialize; +use std::process::Command; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MergeInput { + merge_base: Option, + ours: String, + theirs: String, +} + +pub(crate) fn merge_endpoint( + config: &Config, + request: &mut tiny_http::Request, +) -> Result, HttpError> { + let (input, _): (MergeInput, _) = crate::remote_git::input(request)?; + merge(config, &input) +} + +fn merge(config: &Config, input: &MergeInput) -> Result, HttpError> { + let repo = config.repo.to_thread_local(); + let mut trees = Vec::new(); + let kind = if input.merge_base.is_some() { + gix::object::Kind::Tree + } else { + gix::object::Kind::Commit + }; + for hash in input.merge_base.iter().chain([&input.ours, &input.theirs]) { + if !git_locator::import::commit(hash) { + return Err(HttpError::new( + 400, + "merge-tree requires full object hashes", + )); + } + let id = gix::ObjectId::from_hex(hash.as_bytes()) + .map_err(|_| HttpError::new(400, "invalid tree hash"))?; + let header = repo + .find_header(id) + .map_err(|_| HttpError::new(400, format!("missing object {id}")))?; + if header.kind() != kind { + return Err(HttpError::new(400, format!("{id} is not a {kind}"))); + } + trees.push(id.to_string()); + } + let mut command = Command::new("git"); + command.env_clear(); + if let Some(path) = std::env::var_os("PATH") { + command.env("PATH", path); + } + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_NO_REPLACE_OBJECTS", "1") + .env("GIT_NO_LAZY_FETCH", "1") + .env("GIT_GRAFT_FILE", "/dev/null") + .args([ + "-c", + "core.fsync=objects", + "--git-dir", + &config.git_dir, + "merge-tree", + "--write-tree", + ]); + if input.merge_base.is_some() { + command.arg(format!("--merge-base={}", trees.remove(0))); + } + let result = command.args(&trees).output()?; + // Exit 1 is a usable merged tree with conflicts, not a failed operation. + if !matches!(result.status.code(), Some(0 | 1)) { + return Err(HttpError::new( + 500, + format!( + "git merge-tree failed: {}", + String::from_utf8_lossy(&result.stderr).trim() + ), + )); + } + let output = String::from_utf8(result.stdout) + .map_err(|_| HttpError::new(500, "merge-tree returned non-UTF-8 output"))?; + let (tree, conflicts) = output + .split_once('\n') + .ok_or_else(|| HttpError::new(500, "merge-tree returned no tree"))?; + if !git_locator::import::commit(tree) { + return Err(HttpError::new(500, "merge-tree returned an invalid tree")); + } + crate::storage::head_object(config, tree)?; + Ok(serde_json::to_vec(&serde_json::json!({ + "tree": tree, "conflicts": conflicts + })) + .unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct Store { + path: PathBuf, + config: Config, + } + + impl Store { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "caos-tree-merge-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + let repo = gix::init_bare(&path).unwrap(); + let config = Config { + git_dir: path.to_string_lossy().into_owned(), + repo: repo.into_sync(), + registry_push_url: String::new(), + registry_pull_host: String::new(), + redis_addr: String::new(), + cache_namespace: String::new(), + }; + Self { path, config } + } + + fn tree(&self, files: &[(&str, &str)]) -> String { + let repo = self.config.repo.to_thread_local(); + let mut entries: Vec<_> = files + .iter() + .map(|(name, content)| gix::objs::tree::Entry { + mode: gix::objs::tree::EntryKind::Blob.into(), + filename: (*name).into(), + oid: repo.write_blob(content.as_bytes()).unwrap().detach(), + }) + .collect(); + entries.sort_by(|a, b| a.filename.cmp(&b.filename)); + repo.write_object(&gix::objs::Tree { entries }) + .unwrap() + .to_string() + } + + fn merge(&self, base: &str, ours: &str, theirs: &str) -> serde_json::Value { + let bytes = merge( + &self.config, + &MergeInput { + merge_base: Some(base.into()), + ours: ours.into(), + theirs: theirs.into(), + }, + ) + .unwrap_or_else(|error| panic!("{}", error.message)); + serde_json::from_slice(&bytes).unwrap() + } + } + + impl Drop for Store { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.path).unwrap(); + } + } + + #[test] + fn merges_only_trees_without_commits_index_or_worktree() { + let store = Store::new(); + let base = store.tree(&[("file", "original\n")]); + let ours = store.tree(&[("file", "original\n"), ("ours", "new\n")]); + let theirs = store.tree(&[("file", "changed\n")]); + let result = store.merge(&base, &ours, &theirs); + let expected = store.tree(&[("file", "changed\n"), ("ours", "new\n")]); + assert_eq!(result["tree"], expected); + assert_eq!(result["conflicts"], ""); + assert!(!store.path.join("index").exists()); + assert!(!store.path.join("file").exists()); + assert!(Path::new(&store.config.git_dir).join("objects").is_dir()); + } + + #[test] + fn returns_native_text_and_delete_modify_conflicts() { + let store = Store::new(); + let base = store.tree(&[("file", "original\n")]); + let ours = store.tree(&[("file", "ours\n")]); + let theirs = store.tree(&[("file", "theirs\n")]); + let result = store.merge(&base, &ours, &theirs); + let report = result["conflicts"].as_str().unwrap(); + assert!(report.contains("1\tfile")); + assert!(report.contains("2\tfile")); + assert!(report.contains("3\tfile")); + assert!(report.contains("CONFLICT (content)")); + let repo = store.config.repo.to_thread_local(); + let tree = repo + .find_object( + gix::ObjectId::from_hex(result["tree"].as_str().unwrap().as_bytes()).unwrap(), + ) + .unwrap() + .into_tree(); + let entry = tree.find_entry("file").unwrap(); + let blob = repo.find_object(entry.oid().to_owned()).unwrap(); + assert!(String::from_utf8_lossy(&blob.data).contains("<<<<<<<")); + + let deleted = store.tree(&[]); + let result = store.merge(&base, &deleted, &theirs); + let report = result["conflicts"].as_str().unwrap(); + assert!(report.contains("CONFLICT (modify/delete)")); + assert!(report.contains("1\tfile") && report.contains("3\tfile")); + assert!(!report.contains("2\tfile")); + } + + #[test] + fn source_merge_and_explicit_tree_merge_share_the_same_operation() { + let store = Store::new(); + let base = store.tree(&[("file", "original\n")]); + let ours = store.tree(&[("file", "original\n"), ("ours", "new\n")]); + let theirs = store.tree(&[("file", "changed\n")]); + let commit = |tree: &str, parents: &[&str]| { + let mut cmd = Command::new("git"); + cmd.arg("--git-dir") + .arg(&store.path) + .arg("commit-tree") + .arg(tree); + for parent in parents { + cmd.arg("-p").arg(parent); + } + let out = cmd + .args(["-m", "test"]) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_AUTHOR_DATE", "1700000000 +0000") + .env("GIT_COMMITTER_DATE", "1700000000 +0000") + .output() + .unwrap(); + assert!(out.status.success()); + String::from_utf8(out.stdout).unwrap().trim().to_owned() + }; + let base_commit = commit(&base, &[]); + let ours_commit = commit(&ours, &[&base_commit]); + let theirs_commit = commit(&theirs, &[&base_commit]); + let native: serde_json::Value = serde_json::from_slice( + &merge( + &store.config, + &MergeInput { + merge_base: None, + ours: ours_commit, + theirs: theirs_commit, + }, + ) + .unwrap_or_else(|e| panic!("{}", e.message)), + ) + .unwrap(); + assert_eq!(native, store.merge(&base, &ours, &theirs)); + } + + #[test] + fn rejects_revisions_missing_objects_and_wrong_types() { + let store = Store::new(); + let tree = store.tree(&[]); + let repo = store.config.repo.to_thread_local(); + let blob = repo.write_blob(b"not a tree").unwrap().to_string(); + for bad in ["HEAD".to_string(), "f".repeat(40), blob] { + let error = merge( + &store.config, + &MergeInput { + merge_base: Some(bad), + ours: tree.clone(), + theirs: tree.clone(), + }, + ) + .unwrap_err(); + assert_eq!(error.status, 400); + } + } +} diff --git a/rust/crates/server/src/main.rs b/rust/crates/server/src/main.rs index 1b9e087e8..bcd2dc666 100644 --- a/rust/crates/server/src/main.rs +++ b/rust/crates/server/src/main.rs @@ -43,6 +43,7 @@ mod compute; mod git; +mod git_objects; mod import; mod push; mod remote_git; @@ -716,6 +717,7 @@ fn route(config: &Arc, request: &mut Request) -> Result, HttpErr request.as_reader().read_to_end(&mut body)?; storage::post_object(config, &body) } + Method::Post if path == "/git/merge-tree" => git_objects::merge_endpoint(config, request), Method::Post if path == "/git/import" => import::endpoint(config, request), Method::Post if path == "/git/push" => push::endpoint(config, request), Method::Post if path == "/sub-run" => { diff --git a/std/git-commit-tree/.caos-expr b/std/git-commit-tree/.caos-expr new file mode 100644 index 000000000..3e6f61baa --- /dev/null +++ b/std/git-commit-tree/.caos-expr @@ -0,0 +1,11 @@ +HELP=< <+/-HHMM>. +@param tree Tree ID +@param parents Space-separated parent commit IDs; empty for a root commit +@param author Author signature including timestamp and timezone +@param committer Committer signature including timestamp and timezone +@param message Literal commit message +END +curry --base:@=DEEP-DEPS/bash --worker1:@=worker.sh --help=$HELP diff --git a/std/git-commit-tree/DEPS b/std/git-commit-tree/DEPS new file mode 100644 index 000000000..8aa273717 --- /dev/null +++ b/std/git-commit-tree/DEPS @@ -0,0 +1 @@ +../bash bash diff --git a/std/git-commit-tree/worker.sh b/std/git-commit-tree/worker.sh new file mode 100755 index 000000000..701bdcbe2 --- /dev/null +++ b/std/git-commit-tree/worker.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +for arg in tree parents author committer message; do + caos get "/cas/args/$arg" +done +tree=$(cat /cas/args/tree) +read -r -a parents <<< "$(cat /cas/args/parents)" +for oid in "$tree" "${parents[@]}"; do + if [[ ! "$oid" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "expected a full object hash" >&2 + exit 1 + fi +done +author=$(cat /cas/args/author) +committer=$(cat /cas/args/committer) +for signature in "$author" "$committer"; do + if [[ "$signature" == *$'\n'* || "$signature" == *$'\r'* ]]; then + echo "signature must fit on one line" >&2 + exit 1 + fi +done +{ + printf 'tree %s\n' "$tree" + for parent in "${parents[@]}"; do printf 'parent %s\n' "$parent"; done + printf 'author %s\ncommitter %s\n\n' "$author" "$committer" + cat /cas/args/message +} > /tmp/commit +# put-commit validates the raw commit and its dependencies. +caos put-commit /tmp/commit /cas/commit-result > /tmp/commit-result +caos put /tmp/commit-result /cas/out diff --git a/std/git-merge-tree/.caos-expr b/std/git-merge-tree/.caos-expr new file mode 100644 index 000000000..01b21e305 --- /dev/null +++ b/std/git-merge-tree/.caos-expr @@ -0,0 +1,9 @@ +HELP=< /tmp/merge-result +caos put /tmp/merge-result /cas/out diff --git a/std/merge/flake.nix b/std/merge/flake.nix index ca0d3453b..4ce52abcf 100644 --- a/std/merge/flake.nix +++ b/std/merge/flake.nix @@ -1,25 +1,6 @@ { - # std/merge (design/flake-images.md; SPEC "Merging and conflict resolution"): - # the git-bearing worker image. Unlike std/bash (a script interpreter whose - # /worker runs the curried `worker1`), std/merge bakes the merge logic itself - # as /worker (checked in right here) — it is a complete worker, not an - # interpreter — because merge is one fixed operation, not an arbitrary script. - # The contents are what that script leans on: a shell, coreutils, git (for - # `git merge-tree --write-tree`, index-free and worktree-free), and tar (to - # lift the merged tree out of the local odb). git is the whole reason this is - # a distinct image: no other std worker carries it, and reproducing - # merge-tree's conflict notation in gix (not even a dependency's feature) is - # not worth it. - # - # The contract (std/flake-builder/worker): a flake defines everything about - # the image except the caos additions (/bin/caos, the worker user, - # /usr/bin/env). /worker included. - # - # This directory IS the published tree (literal trees, part 2): flake.nix, - # worker (the lock is DEPped from the repo root and placed by the flake-builder) — - # build-builtins.sh copies it whole, and tests/lint verifies the - # checked-in redundancies. - description = "caos std/merge — the git-bearing merge worker: /worker three-way-merges two commits"; + # Source-merge presentation around the server's object-only Git operation. + description = "caos source merge worker"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -44,11 +25,7 @@ workerRoot pkgs.bash pkgs.coreutils - # gitMinimal carries the plumbing merge-tree needs, no more. - pkgs.gitMinimal - # `git archive | tar -x` lifts the merged tree onto disk so - # `caos put` can re-ingest it. - pkgs.gnutar + pkgs.jq ]; config = { Env = [ "PATH=/bin" ]; diff --git a/std/merge/worker b/std/merge/worker index a4856deba..6c99ddaaf 100755 --- a/std/merge/worker +++ b/std/merge/worker @@ -13,74 +13,57 @@ # (merged tree, parents [ours, theirs]). On conflict the merged tree carries # git's inline markers PLUS a reserved .caos/conflicts file — git's own # unmerged notation, which text markers cannot express for structural -# conflicts (see SPEC). M becomes the run's result at /cas/out. +# conflicts (see SPEC). M becomes the writer result's prop at /cas/out. set -euo pipefail # The commit args materialize as raw commit-object files; `caos hash` reads the # recorded id without fetching content — and the id is all we need, since the -# objects themselves come from the server over git below. +# objects themselves stay on the server. ours=$(caos hash /cas/args/ours) theirs=$(caos hash /cas/args/theirs) -# A throwaway git repo, and the two commit closures fetched from the server's -# own git transport. `--filter=tree:0` fetches the commit GRAPH ONLY — every -# ancestor commit (small: merge-base needs the graph) but NONE of the history's -# trees or blobs. `merge-tree` below then lazily faults in exactly the three -# trees it 3-way-merges and the blobs that differ, via the promisor remote — so -# we transfer the graph plus two end states, not the whole repo. This is why -# the merge is a git-bearing worker: no odb reconstruction, git does it. -# `caos put`/`git push` workers already speak to $CAOS_SERVER_URL this way; a -# bare-hash filtered fetch works because the server sets -# uploadpack.allowAnySHA1InWant and uploadpack.allowFilter. -repo=/tmp/merge-repo -rm -rf "$repo"; mkdir -p "$repo" -git -C "$repo" init -q -git -C "$repo" remote add origin "$CAOS_SERVER_URL" -# Mark origin a promisor remote so later commands lazily fetch missing objects -# from it rather than failing — set explicitly (a manual fetch --filter does -# not always configure this the way `clone --filter` does). -git -C "$repo" config extensions.partialClone origin -git -C "$repo" config remote.origin.promisor true -git -C "$repo" config remote.origin.partialclonefilter tree:0 -git -C "$repo" fetch -q --filter=tree:0 origin "$ours" "$theirs" -timestamp=$(git -C "$repo" show -s --format=%ct "$ours") +# Both source merging and replay use the server's object-only merge endpoint. +caos git-merge-tree "$ours" "$theirs" > /tmp/merged.json +merged_tree=$(jq -r .tree /tmp/merged.json) +code=0 +if [ "$(jq -r '.conflicts | length' /tmp/merged.json)" != 0 ]; then code=1; fi -# `merge-tree --write-tree`: writes the merged tree to the odb and prints its -# oid on line 1; on conflict, exits 1 and prints the unmerged-file notation and -# informational messages after it. Any other exit is a real failure. -set +e -merge_out=$(git -C "$repo" merge-tree --write-tree "$ours" "$theirs") -code=$? -set -e -if [ "$code" != 0 ] && [ "$code" != 1 ]; then - echo "merge: git merge-tree failed (exit $code):" >&2 - printf '%s\n' "$merge_out" >&2 - exit 1 -fi -# Line 1 is the tree oid; the rest (conflict case) is the notation for -# .caos/conflicts. Parameter expansion, not `head`/`tail` pipes — a -# `printf | head -1` can SIGPIPE and trip `pipefail` (AGENTS.md). -merged_tree=${merge_out%%$'\n'*} - -# Lift the merged tree out of the odb onto disk so caos can re-ingest it. -# Re-hashing reproduces the same tree (git hashing is deterministic); on a -# clean merge the caos tree IS merged_tree, on a conflict it is that plus -# .caos/conflicts. +caos get /cas/args/ours +timestamp= +while IFS= read -r line; do + if [[ "$line" == committer\ * ]]; then + timestamp=${line% *} + timestamp=${timestamp##* } + break + fi +done < /cas/args/ours +: "${timestamp:?missing committer timestamp}" +mtree=$merged_tree work=/tmp/merge-work -rm -rf "$work"; mkdir -p "$work" -git -C "$repo" archive "$merged_tree" | tar -x -C "$work" - if [ "$code" = 1 ]; then - # The authoritative conflict set: everything after the tree oid, verbatim — - # git's ls-files -u rows plus its informational-messages block. The agent - # resolves a path by editing it and deleting its rows here (SPEC). + caos get-hash "$merged_tree" /cas/merged mkdir -p "$work/.caos" - printf '%s\n' "${merge_out#*$'\n'}" > "$work/.caos/conflicts" + shopt -s dotglob nullglob + for entry in /cas/merged/*; do + name=${entry##*/} + if [ "$name" != .caos ]; then ln -s "$entry" "$work/$name"; fi + done + if [ -e /cas/merged/.caos ] && [ ! -d /cas/merged/.caos ]; then + echo "merge: .caos must be a directory" >&2 + exit 1 + fi + if [ -d /cas/merged/.caos ]; then + caos get /cas/merged/.caos + for entry in /cas/merged/.caos/*; do + name=${entry##*/} + if [ "$name" != conflicts ]; then ln -s "$entry" "$work/.caos/$name"; fi + done + fi + jq -rj .conflicts /tmp/merged.json > "$work/.caos/conflicts" + caos put "$work" /cas/merged-with-conflicts + mtree=$(caos hash /cas/merged-with-conflicts) fi -caos put "$work" /cas/merged -mtree=$(caos hash /cas/merged) - # Mint M = (merged tree, parents [ours, theirs]). Inheriting ours' timestamp # keeps a merge of the same two commits a pure value while giving conversation # merges a useful date. put-commit at /cas/out makes `commit ` the result. diff --git a/tests/merge/worker.sh b/tests/merge/worker.sh index 543d11262..0a0723888 100755 --- a/tests/merge/worker.sh +++ b/tests/merge/worker.sh @@ -4,8 +4,7 @@ # Drives the std/merge worker directly (SPEC "Merging and conflict # resolution"): build two commits, merge them, and assert the two-parent result # — a conflicted merge (inline markers + .caos/conflicts) and a clean one. The -# git-bearing worker fetches both commit closures from the server's own -# transport, so this also exercises that path. +# server performs the merge directly over its stored Git objects. # # GIT WAS A LOCAL TOOL HERE, NOT A SUBJECT, which is why this needs none. # `caos put-commit` mints a commit — the raw object, validated client-side and