Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 9 additions & 27 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -728,33 +728,15 @@ limits of commit-based fetch negotiation.

## `merge --theirs=<commit>`

- 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
<ours> <theirs>` 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=<merge script>)`,
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`

Expand Down
3 changes: 2 additions & 1 deletion rust/crates/caos/src/bin/caos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 <https-url> <commit> [--github-token-file=<path>]\n {prog} push-git <https-url> <commit> <branch> --expected=<hash|absent> [--github-token-file=<path>]\n {prog} resolve <hash> <relative-path> <cas-path>\n {prog} kind <cas-path>\n {prog} get-hash <hash> <path>\n \
"usage:\n {prog} git-merge-tree <merge-base-tree> <ours-tree> <theirs-tree>\n {prog} import-git <https-url> <commit> [--github-token-file=<path>]\n {prog} push-git <https-url> <commit> <branch> --expected=<hash|absent> [--github-token-file=<path>]\n {prog} resolve <hash> <relative-path> <cas-path>\n {prog} kind <cas-path>\n {prog} get-hash <hash> <path>\n \
{prog} get [-r | --recursive[=<depth>]] <path>\n \
{prog} put <src-path> <cas-path>\n \
{prog} put-commit <src-file> <cas-path>\n \
Expand Down
69 changes: 69 additions & 0 deletions rust/crates/caos/src/git_objects.rs
Original file line number Diff line number Diff line change
@@ -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<MergeTree, String> {
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<String, String> {
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 <base-tree> <ours-tree> <theirs-tree>, or <ours-commit> <theirs-commit>".into()),
};
let result = merge_tree(&crate::HttpTransport::from_env()?, base, ours, theirs)?;
println!(
"{}",
serde_json::json!({"tree":result.tree, "conflicts":result.conflicts})
);
Ok(())
}
1 change: 1 addition & 0 deletions rust/crates/caos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading