From 4d45e419eb3ec3072b72fb1b19eee937d0f296ab Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 25 Sep 2026 06:02:12 +0000 Subject: [PATCH] Replay source stacks from deterministic plans --- SPEC.md | 15 +- std/git-rebase-i/.caos-expr | 13 + std/git-rebase-i/DEPS | 1 + std/git-stack/.caos-expr | 1 + std/git-stack/Cargo.toml | 6 + std/git-stack/DEPS | 6 + std/git-stack/src/main.rs | 37 +++ std/git-stack/src/rebase.rs | 144 +++++++++++ std/git-stack/src/rebase_plan.rs | 253 +++++++++++++++++++ std/git-stack/src/rebase_plan_tests.rs | 331 +++++++++++++++++++++++++ std/git-stack/src/rebase_tests.rs | 205 +++++++++++++++ std/llm-step/src/source_trees.rs | 14 +- tests/llm-rebase/.caos-expr | 1 + tests/llm-rebase/DEPS | 8 + tests/llm-rebase/worker.sh | 151 +++++++++++ 15 files changed, 1176 insertions(+), 10 deletions(-) create mode 100644 std/git-rebase-i/.caos-expr create mode 100644 std/git-rebase-i/DEPS create mode 100644 std/git-stack/.caos-expr create mode 100644 std/git-stack/DEPS create mode 100644 std/git-stack/src/main.rs create mode 100644 std/git-stack/src/rebase.rs create mode 100644 std/git-stack/src/rebase_plan.rs create mode 100644 std/git-stack/src/rebase_plan_tests.rs create mode 100644 std/git-stack/src/rebase_tests.rs create mode 100644 tests/llm-rebase/.caos-expr create mode 100644 tests/llm-rebase/DEPS create mode 100755 tests/llm-rebase/worker.sh diff --git a/SPEC.md b/SPEC.md index 851684f0..a7d0806c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -840,6 +840,15 @@ nothing; run `/pr` again after integration. Successful pushes and PR operations are recorded as CAOS transcript entries. No snapshot has a special working or sealed state. -Per-mutation commits remain in the published source tree history. Only the -previewed PR tip is checked for unresolved conflicts and reserved state; -intermediate commits may contain conflict markers or fail to build. +Publication sends the selected commit without rewriting its history. Prepare +that history with a replay plan before publishing. Only the previewed PR tip +is checked for unresolved conflicts and reserved state; intermediate commits +may contain conflict markers or fail to build. + +## Stack replay + +Numbered source gitlinks pair with text `.base` files recording their original +predecessors. `std/git-rebase-i` runs a deterministic plan over one feature +directory, returning a replacement subtree through `run_tool`'s scoped writer +path. It runs from the beginning on every invocation; conflicts are resolved +by editing a draft and updating the plan. See [agent stacks and replay](design/agent-rebase.md). diff --git a/std/git-rebase-i/.caos-expr b/std/git-rebase-i/.caos-expr new file mode 100644 index 00000000..f945d9ec --- /dev/null +++ b/std/git-rebase-i/.caos-expr @@ -0,0 +1,13 @@ +HELP=< <+/-HHMM> +pick=COMMIT replays one change; pick=A..B applies the endpoint tree difference as one commit +message=path replaces the latest unsealed commit message with that scope-relative file's bytes +branch=name records a layer, numbered automatically from 00 +Each run starts from the beginning and deterministically recreates the same commits +Conflicts leave rebase/work and rebase/conflicts; resolve the draft to R, replace the failed pick with pick=H..R using the reported output tip H, then run again +Completion replaces numbered entries, preserves other files and removes rebase +@writer +@in +END +curry --base:@=DEEP-DEPS/git-stack --help=$HELP diff --git a/std/git-rebase-i/DEPS b/std/git-rebase-i/DEPS new file mode 100644 index 00000000..8278af58 --- /dev/null +++ b/std/git-rebase-i/DEPS @@ -0,0 +1 @@ +../git-stack git-stack diff --git a/std/git-stack/.caos-expr b/std/git-stack/.caos-expr new file mode 100644 index 00000000..00fe49ff --- /dev/null +++ b/std/git-stack/.caos-expr @@ -0,0 +1 @@ +run --base:@=DEEP-DEPS/rustc --src:@=. --dep0:@=DEEP-DEPS/conversation-protocol --dep1:@=DEEP-DEPS/git-locator --dep2:@=DEEP-DEPS/caos --dep3:@=DEEP-DEPS/caos-eval --dep4:@=DEEP-DEPS/caos-world diff --git a/std/git-stack/Cargo.toml b/std/git-stack/Cargo.toml index e59634c8..3c62b823 100644 --- a/std/git-stack/Cargo.toml +++ b/std/git-stack/Cargo.toml @@ -7,8 +7,14 @@ edition = "2021" name = "git_stack" path = "src/objects.rs" +[[bin]] +name = "worker" +path = "src/main.rs" + [dependencies] +worker-common = { path = "worker-common" } conversation-protocol = { path = "conversation-protocol" } +serde_json = "1" caos = { path = "caos" } [dev-dependencies] diff --git a/std/git-stack/DEPS b/std/git-stack/DEPS new file mode 100644 index 00000000..79f2fbb5 --- /dev/null +++ b/std/git-stack/DEPS @@ -0,0 +1,6 @@ +../rustc rustc +../../rust/crates/conversation-protocol conversation-protocol +../../rust/crates/git-locator git-locator +../../rust/crates/caos caos +../../rust/crates/caos-eval caos-eval +../../rust/crates/caos-world caos-world diff --git a/std/git-stack/src/main.rs b/std/git-stack/src/main.rs new file mode 100644 index 00000000..bb7c0e1d --- /dev/null +++ b/std/git-stack/src/main.rs @@ -0,0 +1,37 @@ +//! Replay a plan over a stack directory without checking out its source trees. +use git_stack as objects; +mod rebase; +mod rebase_plan; + +use conversation_protocol::v3::{Mode, ObjectStore, Oid, TreeBuilder}; +use worker_common::{arg, caos, cas_hash, run_worker}; + +fn run() -> Result<(), String> { + let input = Oid::parse(&cas_hash(&arg("in"))?, "input stack tree")?; + let mut store = objects::RemoteStore::from_env()?; + let (proposal, failed) = match rebase::propose(&mut store, &input) { + Ok(proposal) => (proposal, false), + Err(error) => ( + rebase::Proposal { + tree: input, + report: format!("Replay was not applied: {error}\n"), + }, + true, + ), + }; + let out = store + .write_blob(proposal.report.as_bytes()) + .map_err(String::from)?; + let mut result = TreeBuilder::from(None); + result.put_oid("prop", Mode::Tree, proposal.tree); + result.put_oid("out", Mode::Blob, out); + if failed { + result.put("failed", Mode::Blob, Vec::new()); + } + let result = result.build(&mut store)?; + caos(["get-hash", result.as_str(), "/cas/out"]) +} + +fn main() -> std::process::ExitCode { + run_worker("git-stack", run) +} diff --git a/std/git-stack/src/rebase.rs b/std/git-stack/src/rebase.rs new file mode 100644 index 00000000..02b56dcb --- /dev/null +++ b/std/git-stack/src/rebase.rs @@ -0,0 +1,144 @@ +//! Read a scoped plan and return a replacement stack tree. +use crate::rebase_plan; +use conversation_protocol::v3::{CommitInfo, Mode, ObjectStore, Oid, Snapshot, TreeBuilder}; +use std::fmt::Write; +use std::process::Command; + +#[derive(Debug)] +pub struct Proposal { + pub tree: Oid, + pub report: String, +} + +fn blob(store: &dyn ObjectStore, root: &Oid, path: &str) -> Result, String> { + let entry = Snapshot::new(store, root.clone()) + .entry(path)? + .ok_or_else(|| format!("missing file {path}"))?; + if entry.mode != Mode::Blob { + return Err(format!("{path} must be a regular file")); + } + store.read_blob(&entry.oid).map_err(String::from) +} + +pub fn propose(store: &mut dyn ObjectStore, input: &Oid) -> Result { + let plan = String::from_utf8(blob(store, input, "rebase/plan")?) + .map_err(|_| "rebase/plan must be UTF-8")?; + let outcome = rebase_plan::run(&mut Backend { store, input }, &plan)?; + apply(store, input, &plan, outcome) +} + +fn numbered(name: &str, mode: Mode) -> bool { + let digits = name.bytes().take_while(u8::is_ascii_digit).count(); + digits > 0 + && ((mode == Mode::Commit && name[digits..].starts_with('-')) + || (mode == Mode::Blob && name[digits..] == *".base")) +} + +fn apply( + store: &mut dyn ObjectStore, + input: &Oid, + plan: &str, + outcome: rebase_plan::Outcome, +) -> Result { + let mut replacement = TreeBuilder::from(Some(input.clone())); + let mut report = String::new(); + if let Some(conflict) = outcome.conflict { + replacement.put_oid("rebase/work", Mode::Commit, conflict.draft.clone()); + replacement.put("rebase/conflicts", Mode::Blob, conflict.report); + writeln!(report, "Conflict at rebase/plan line {}", conflict.line).unwrap(); + writeln!(report, "Output tip: {}", conflict.parent).unwrap(); + writeln!(report, "Draft: rebase/work ({})", conflict.draft).unwrap(); + writeln!(report, "Git report: rebase/conflicts").unwrap(); + writeln!(report, + "Resolve rebase/work, get its commit R, replace the failed pick with pick={}..R, then run the plan again.", + conflict.parent).unwrap(); + } else { + let entries = store.read_tree(input).map_err(String::from)?; + for entry in &entries { + if numbered(&entry.name, entry.mode) { + replacement.delete(&entry.name); + } + } + for (number, layer) in outcome.layers.iter().enumerate() { + let base_name = format!("{number:02}.base"); + for name in [&layer.name, &base_name] { + if entries + .iter() + .any(|entry| entry.name == *name && !numbered(&entry.name, entry.mode)) + { + return Err(format!( + "output {name} would replace an ordinary stack file" + )); + } + } + replacement.put_oid(&layer.name, Mode::Commit, layer.tip.clone()); + replacement.put(&base_name, Mode::Blob, layer.base.encode_line()); + writeln!(report, "{} {}", layer.name, layer.tip).unwrap(); + } + replacement.delete("rebase"); + writeln!(report, "\nCompleted plan:\n{plan}").unwrap(); + } + Ok(Proposal { + tree: replacement.build(store)?, + report, + }) +} + +struct Backend<'s> { + store: &'s mut dyn ObjectStore, + input: &'s Oid, +} + +impl rebase_plan::Backend for Backend<'_> { + fn read_commit(&mut self, oid: &Oid) -> Result { + self.store.read_commit(oid).map_err(String::from) + } + + fn read_message(&mut self, path: &str) -> Result, String> { + blob(self.store, self.input, path) + } + + fn merge_trees( + &mut self, + base: &Oid, + ours: &Oid, + theirs: &Oid, + ) -> Result { + let output = Command::new("caos") + .args([ + "git-merge-tree", + base.as_str(), + ours.as_str(), + theirs.as_str(), + ]) + .output() + .map_err(|e| format!("running merge-tree: {e}"))?; + if !output.status.success() { + return Err(format!( + "merge-tree: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let value: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("invalid merge-tree result: {e}"))?; + let tree = Oid::parse( + value["tree"].as_str().ok_or("merge-tree lacks tree")?, + "merged tree", + )?; + let report = value["conflicts"] + .as_str() + .ok_or("merge-tree lacks report")?; + Ok(rebase_plan::Merge { + tree, + conflicts: (!report.is_empty()).then(|| report.as_bytes().to_vec()), + }) + } + + fn commit_tree(&mut self, commit: &CommitInfo) -> Result { + self.store.write_commit(commit).map_err(String::from) + } +} + +#[cfg(test)] +#[path = "rebase_tests.rs"] +mod tests; diff --git a/std/git-stack/src/rebase_plan.rs b/std/git-stack/src/rebase_plan.rs new file mode 100644 index 00000000..306681a5 --- /dev/null +++ b/std/git-stack/src/rebase_plan.rs @@ -0,0 +1,253 @@ +//! Deterministic replay of endpoint tree differences. Every call runs the plan +//! from its beginning; the input plan is never rewritten with execution state. +use conversation_protocol::v3::{paths, CommitInfo, Oid, Signature}; + +pub trait Backend { + fn read_commit(&mut self, oid: &Oid) -> Result; + fn read_message(&mut self, path: &str) -> Result, String>; + fn merge_trees(&mut self, base: &Oid, ours: &Oid, theirs: &Oid) -> Result; + fn commit_tree(&mut self, commit: &CommitInfo) -> Result; +} + +#[derive(Debug)] +pub struct Merge { + pub tree: Oid, + pub conflicts: Option>, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Layer { + pub name: String, + pub tip: Oid, + pub base: Oid, +} + +#[derive(Debug)] +pub struct Conflict { + pub line: usize, + pub parent: Oid, + pub draft: Oid, + pub report: Vec, +} + +#[derive(Debug)] +pub struct Outcome { + pub layers: Vec, + pub conflict: Option, +} + +#[derive(Debug)] +enum Selection { + Single(Oid), + Range(Oid, Oid), +} + +#[derive(Debug)] +enum Instruction { + Pick(Selection), + Message(String), + Branch(String), +} + +pub struct Plan { + onto: Oid, + committer: Signature, + instructions: Vec<(usize, Instruction)>, +} + +impl Plan { + pub fn parse(text: &str) -> Result { + let mut lines = text.lines().enumerate().filter_map(|(index, line)| { + let line = line.trim(); + (!line.is_empty() && !line.starts_with('#')).then_some((index + 1, line)) + }); + let (_, first) = lines.next().ok_or("replay plan is empty")?; + let onto = Oid::parse( + first + .strip_prefix("onto=") + .ok_or("first instruction must be onto=")? + .trim(), + "onto", + )?; + let (_, second) = lines.next().ok_or("plan needs an explicit committer")?; + let committer = crate::objects::parse_signature( + second + .strip_prefix("committer=") + .ok_or( + "second instruction must be committer=Name <+/-HHMM>", + )? + .trim(), + )?; + let mut instructions = Vec::new(); + let mut unsealed = false; + let mut branches = 0; + for (line, text) in lines { + let (command, value) = text + .split_once('=') + .ok_or_else(|| format!("line {line}: expected command=value"))?; + let value = value.trim(); + let instruction = match command { + "pick" => { + let selection = match value.split_once("..") { + Some((from, to)) => Selection::Range( + Oid::parse(from, "range start")?, + Oid::parse(to, "range end")?, + ), + None => Selection::Single(Oid::parse(value, "picked commit")?), + }; + unsealed = true; + Instruction::Pick(selection) + } + "message" => { + if !unsealed { + return Err(format!( + "line {line}: message requires a pick since the last branch" + )); + } + paths::validate_source_tree_name(value)?; + Instruction::Message(value.into()) + } + "branch" => { + paths::validate_component(value)?; + if value.chars().any(char::is_whitespace) { + return Err(format!( + "line {line}: branch name cannot contain whitespace" + )); + } + // Numbers are generated, so accepting a supplied prefix would + // silently produce misleading names such as 00-01-feature. + if value.split_once('-').is_some_and(|(prefix, _)| { + !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()) + }) { + return Err(format!( + "line {line}: branch takes a name without a number prefix" + )); + } + branches += 1; + unsealed = false; + Instruction::Branch(value.into()) + } + _ => return Err(format!("line {line}: unknown replay command {command}")), + }; + instructions.push((line, instruction)); + } + if branches == 0 || unsealed { + return Err("the final output tip must be recorded by branch=".into()); + } + Ok(Self { + onto, + committer, + instructions, + }) + } +} + +pub fn run(backend: &mut impl Backend, source: &str) -> Result { + let plan = Plan::parse(source)?; + let mut tip = plan.onto; + let mut current = backend.read_commit(&tip)?; + let mut layer_base = tip.clone(); + let mut layers = Vec::new(); + for (line, instruction) in plan.instructions { + match instruction { + Instruction::Branch(name) => { + layers.push(Layer { + name: format!("{:02}-{name}", layers.len()), + tip: tip.clone(), + base: layer_base, + }); + layer_base = tip.clone(); + } + Instruction::Message(path) => { + // Read at execution, not while parsing: a later missing message + // must not prevent an earlier conflict from being returned. + let message = backend.read_message(&path)?; + if current.message != message { + current.message = message; + current.committer = plan.committer.clone(); + current.extra_headers = rewritten_headers(¤t.extra_headers, true); + tip = backend.commit_tree(¤t)?; + } + } + Instruction::Pick(selection) => { + let (from, to, single) = match selection { + Selection::Single(to) => { + let selected = backend.read_commit(&to)?; + let [parent] = selected.parents.as_slice() else { + return Err(format!( + "line {line}: single-commit pick requires exactly one parent: {to}" + )); + }; + (parent.clone(), to, true) + } + Selection::Range(from, to) => (from, to, false), + }; + let selected = backend.read_commit(&to)?; + if single && from == tip { + // Reusing the object preserves all source metadata, including + // committer and valid signatures. The plan committer applies + // only to commits that actually have to be created. + tip = to; + current = selected; + continue; + } + let base = backend.read_commit(&from)?; + let merge = backend.merge_trees(&base.tree, ¤t.tree, &selected.tree)?; + if let Some(report) = merge.conflicts { + let draft = backend.commit_tree(&CommitInfo { + tree: merge.tree, + parents: vec![tip.clone()], + author: plan.committer.clone(), + committer: plan.committer.clone(), + extra_headers: Vec::new(), + message: b"rebase conflict\n".to_vec(), + })?; + return Ok(Outcome { + layers, + conflict: Some(Conflict { + line, + parent: tip, + draft, + report, + }), + }); + } + current = CommitInfo { + tree: merge.tree, + parents: vec![tip], + author: selected.author, + committer: plan.committer.clone(), + extra_headers: rewritten_headers(&selected.extra_headers, false), + message: selected.message, + }; + tip = backend.commit_tree(¤t)?; + } + } + } + Ok(Outcome { + layers, + conflict: None, + }) +} + +// Continuations belong to their header. Rewritten commits cannot retain +// signatures or mergetags; replacement messages no longer use source encoding. +fn rewritten_headers(headers: &[u8], replace_message: bool) -> Vec { + let mut result = Vec::new(); + let mut keep = false; + for line in headers.split_inclusive(|byte| *byte == b'\n') { + if !line.starts_with(b" ") { + let name = line.split(|byte| *byte == b' ').next().unwrap_or_default(); + keep = !matches!(name, b"gpgsig" | b"gpgsig-sha256" | b"mergetag") + && !(replace_message && name == b"encoding"); + } + if keep { + result.extend_from_slice(line); + } + } + result +} + +#[cfg(test)] +#[path = "rebase_plan_tests.rs"] +mod tests; diff --git a/std/git-stack/src/rebase_plan_tests.rs b/std/git-stack/src/rebase_plan_tests.rs new file mode 100644 index 00000000..05550365 --- /dev/null +++ b/std/git-stack/src/rebase_plan_tests.rs @@ -0,0 +1,331 @@ +use super::*; +use conversation_protocol::v3::{MemoryStore, ObjectStore}; +use std::collections::HashMap; + +fn signature(time: i64) -> Signature { + Signature { + name: "Replay".into(), + email: "test@example.com".into(), + time, + offset: "+0000".into(), + } +} +fn oid(byte: char) -> Oid { + Oid::parse(&byte.to_string().repeat(40), "test").unwrap() +} +fn plan(onto: &Oid, body: &str) -> String { + format!("onto={onto}\ncommitter=Replay 99 +0000\n{body}") +} + +#[derive(Default)] +struct Fixture { + store: MemoryStore, + messages: HashMap>, + reads: Vec, + merges: Vec<(Oid, Oid, Oid)>, + conflict: Option<(Oid, Oid, Oid)>, +} +impl Fixture { + fn commit(&mut self, tree: char, parents: Vec, time: i64) -> Oid { + self.store + .write_commit(&CommitInfo { + tree: oid(tree), + parents, + author: signature(time), + committer: signature(time), + extra_headers: Vec::new(), + message: format!("source {time}\n").into_bytes(), + }) + .unwrap() + } + fn info(&self, oid: &Oid) -> CommitInfo { + self.store.read_commit(oid).unwrap() + } +} +impl Backend for Fixture { + fn read_commit(&mut self, oid: &Oid) -> Result { + self.reads.push(oid.clone()); + self.store.read_commit(oid).map_err(String::from) + } + fn read_message(&mut self, path: &str) -> Result, String> { + self.messages + .get(path) + .cloned() + .ok_or_else(|| format!("missing message {path}")) + } + fn merge_trees(&mut self, base: &Oid, ours: &Oid, theirs: &Oid) -> Result { + let triple = (base.clone(), ours.clone(), theirs.clone()); + self.merges.push(triple.clone()); + if self.conflict.as_ref() == Some(&triple) { + return Ok(Merge { + tree: oid('f'), + conflicts: Some(b"native conflict\0report\n".to_vec()), + }); + } + // Tests assert each triple separately. The endpoint tree is sufficient + // to exercise commit metadata and deterministic replay here; the worker + // fixture exercises actual three-way Git merging. + Ok(Merge { + tree: if base == theirs { + ours.clone() + } else { + theirs.clone() + }, + conflicts: None, + }) + } + fn commit_tree(&mut self, commit: &CommitInfo) -> Result { + self.store.write_commit(commit).map_err(String::from) + } +} + +#[test] +fn range_reads_only_endpoints_and_builds_one_commit() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![oid('9')], 900); // Intermediate history is deliberately unavailable. + let h = f.commit('c', vec![a.clone()], 2); + let source = plan(&h, &format!("pick={a}..{b}\nbranch=feature\nbranch=work\n")); + let out = run(&mut f, &source).unwrap(); + let commit = f.info(&out.layers[0].tip); + assert_eq!(commit.parents, vec![h.clone()]); + assert_eq!(commit.tree, oid('b')); + assert_eq!(commit.author, signature(900)); + assert_eq!(commit.committer, signature(99)); + assert_eq!(commit.message, b"source 900\n"); + assert_eq!(f.merges, vec![(oid('a'), oid('c'), oid('b'))]); + assert!(f + .reads + .iter() + .all(|read| [a.clone(), b.clone(), h.clone()].contains(read))); + assert_eq!(out.layers[0].name, "00-feature"); + assert_eq!(out.layers[0].base, h); + assert_eq!(out.layers[1].name, "01-work"); + assert_eq!(out.layers[1].base, out.layers[0].tip); + assert_eq!(out.layers[1].tip, out.layers[0].tip); + assert_eq!(run(&mut f, &source).unwrap().layers, out.layers); +} + +#[test] +fn aligned_single_pick_preserves_object_and_all_metadata() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + let mut source = f.info(&b); + source.extra_headers = b"gpgsig original\n continuation\nx-custom data\n".to_vec(); + let b = f.store.write_commit(&source).unwrap(); + let out = run(&mut f, &plan(&a, &format!("pick={b}\nbranch=feature\n"))).unwrap(); + assert_eq!(out.layers[0].tip, b); + assert_eq!(f.info(&b), source); + assert!(f.merges.is_empty()); +} + +#[test] +fn one_commit_range_is_still_an_explicit_new_commit() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + let out = run( + &mut f, + &plan(&a, &format!("pick={a}..{b}\nbranch=feature\n")), + ) + .unwrap(); + assert_ne!(out.layers[0].tip, b); + assert_eq!(f.info(&out.layers[0].tip).committer, signature(99)); +} + +#[test] +fn prefix_repeats_exactly_when_suffix_changes() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + let c = f.commit('c', vec![b.clone()], 3); + let h = f.commit('d', vec![a.clone()], 4); + let prefix = format!("pick={b}\nbranch=first\n"); + let first = run( + &mut f, + &plan(&h, &format!("{prefix}pick={c}\nbranch=second\n")), + ) + .unwrap(); + f.messages + .insert("message".into(), b"replacement\n".to_vec()); + let changed = run( + &mut f, + &plan( + &h, + &format!("{prefix}pick={c}\nmessage=message\nbranch=renamed\n"), + ), + ) + .unwrap(); + assert_eq!(first.layers[0], changed.layers[0]); + assert_ne!(first.layers[1].tip, changed.layers[1].tip); + assert_eq!(changed.layers[1].base, first.layers[0].tip); +} + +#[test] +fn conflict_repair_is_an_explicit_range_and_does_not_import_draft_history() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + let c = f.commit('c', vec![a.clone()], 3); + let h = f.commit('d', vec![a.clone()], 4); + let prefix = format!("pick={b}\nbranch=lower\n"); + f.conflict = Some((oid('a'), oid('b'), oid('c'))); + let source = plan(&h, &format!("{prefix}pick={c}\nbranch=upper\n")); + let first = run(&mut f, &source).unwrap(); + let conflict = first.conflict.unwrap(); + let again = run(&mut f, &source).unwrap().conflict.unwrap(); + assert_eq!(conflict.draft, again.draft); + assert_eq!(conflict.parent, first.layers[0].tip); + assert_eq!(conflict.line, 5); + assert_eq!( + f.info(&conflict.draft).parents, + vec![conflict.parent.clone()] + ); + assert_eq!(conflict.report, b"native conflict\0report\n"); + let draft_edit = f.commit('e', vec![conflict.draft], 20); + let resolved = f.commit('e', vec![draft_edit], 21); + let repaired = plan( + &h, + &format!( + "{prefix}pick={}..{resolved}\nbranch=upper\n", + conflict.parent + ), + ); + let out = run(&mut f, &repaired).unwrap(); + assert!(out.conflict.is_none()); + assert_eq!(out.layers[0], first.layers[0]); + let final_commit = f.info(&out.layers[1].tip); + assert_eq!(final_commit.parents, vec![conflict.parent]); + assert_eq!(final_commit.tree, oid('e')); + assert_ne!(out.layers[1].tip, resolved); + assert_eq!(run(&mut f, &repaired).unwrap().layers, out.layers); +} + +#[test] +fn missing_later_message_is_not_read_before_conflict() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + let h = f.commit('c', vec![a.clone()], 3); + f.conflict = Some((oid('a'), oid('c'), oid('b'))); + let source = plan( + &h, + &format!("pick={b}\nmessage=messages/not-yet-written\nbranch=feature\n"), + ); + assert!(run(&mut f, &source).unwrap().conflict.is_some()); + f.conflict = None; + assert!(run(&mut f, &source) + .unwrap_err() + .contains("missing message")); +} + +#[test] +fn message_reads_exact_bytes_and_same_message_preserves_original_commit() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + f.messages.insert("msg".into(), f.info(&b).message); + let source = plan(&a, &format!("pick={b}\nmessage=msg\nbranch=feature\n")); + assert_eq!(run(&mut f, &source).unwrap().layers[0].tip, b); + let bytes = b" new subject \n\nbody=literal # text\n\n"; + f.messages.insert("msg".into(), bytes.to_vec()); + let out = run(&mut f, &source).unwrap(); + let info = f.info(&out.layers[0].tip); + assert_eq!(info.message, bytes); + assert_eq!(info.parents, vec![a]); + assert_eq!(info.author, signature(2)); + assert_eq!(info.committer, signature(99)); + assert_eq!(info.tree, oid('b')); + assert_eq!(run(&mut f, &source).unwrap().layers, out.layers); +} + +#[test] +fn rewriting_preserves_encoded_source_and_removes_invalidated_signatures() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + let h = f.commit('c', vec![a.clone()], 3); + let mut info = f.info(&b); + info.message = b"caf\xe9\n".to_vec(); + info.extra_headers = b"gpgsig signature\n continuation\nencoding ISO-8859-1\nx-custom value\n continued\ngpgsig-sha256 sig\n continuation\nmergetag object hash\n type commit\n".to_vec(); + let b = f.store.write_commit(&info).unwrap(); + let prefix = format!("pick={b}\n"); + let out = run(&mut f, &plan(&h, &format!("{prefix}branch=feature\n"))).unwrap(); + let info = f.info(&out.layers[0].tip); + assert_eq!(info.message, b"caf\xe9\n"); + assert_eq!( + info.extra_headers, + b"encoding ISO-8859-1\nx-custom value\n continued\n" + ); + f.messages.insert("msg".into(), b"fresh message\n".to_vec()); + let out = run( + &mut f, + &plan(&h, &format!("{prefix}message=msg\nbranch=feature\n")), + ) + .unwrap(); + assert_eq!( + f.info(&out.layers[0].tip).extra_headers, + b"x-custom value\n continued\n" + ); +} + +#[test] +fn empty_range_keeps_an_empty_commit_and_multiple_picks_keep_multiple_commits() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let b = f.commit('b', vec![a.clone()], 2); + let out = run( + &mut f, + &plan(&a, &format!("pick={a}..{a}\npick={b}\nbranch=feature\n")), + ) + .unwrap(); + let last = f.info(&out.layers[0].tip); + let empty = f.info(&last.parents[0]); + assert_eq!(empty.parents, vec![a.clone()]); + assert_eq!(empty.tree, f.info(&a).tree); + assert_eq!(last.tree, f.info(&b).tree); +} + +#[test] +fn root_and_merge_single_picks_require_an_explicit_range() { + let mut f = Fixture::default(); + let a = f.commit('a', vec![], 1); + let merge = f.commit('b', vec![a.clone(), a.clone()], 2); + for picked in [&a, &merge] { + assert!(run( + &mut f, + &plan(&a, &format!("pick={picked}\nbranch=feature\n")) + ) + .unwrap_err() + .contains("exactly one parent")); + } +} + +#[test] +fn grammar_requires_signature_sealed_tip_and_scoped_message_paths() { + let a = oid('a'); + for body in [ + format!("pick={a}\n"), + "message=msg\nbranch=x\n".into(), + format!("pick={a}\nbranch=x\nmessage=msg\n"), + "branch=00-x\n".into(), + "branch=../x\n".into(), + "branch=x y\n".into(), + format!("pick={a}\nmessage=../msg\nbranch=x\n"), + format!("pick={a}\nmessage=/msg\nbranch=x\n"), + format!("squash={a}\nbranch=x\n"), + format!("drop={a}\nbranch=x\n"), + "amend=x\nbranch=x\n".into(), + "continue=true\nbranch=x\n".into(), + format!("done {a} pick={a}\nbranch=x\n"), + ] { + assert!(Plan::parse(&plan(&a, &body)).is_err(), "{body}"); + } + assert!(Plan::parse(&format!("onto={a}\nbranch=x\n")).is_err()); + assert!(Plan::parse(&format!( + "committer=Replay 1 +0000\nonto={a}\nbranch=x\n" + )) + .is_err()); + assert!(Plan::parse(&plan(&a, "# note\n\nbranch=x\nbranch=x\n")).is_ok()); +} diff --git a/std/git-stack/src/rebase_tests.rs b/std/git-stack/src/rebase_tests.rs new file mode 100644 index 00000000..b2448a31 --- /dev/null +++ b/std/git-stack/src/rebase_tests.rs @@ -0,0 +1,205 @@ +use super::*; +use conversation_protocol::v3::{MemoryStore, Signature}; + +fn signature() -> Signature { + Signature { + name: "Replay".into(), + email: "test@example.com".into(), + time: 1, + offset: "+0000".into(), + } +} +fn source(store: &mut MemoryStore, text: &str, parents: Vec) -> Oid { + let mut tree = TreeBuilder::from(None); + tree.put("file", Mode::Blob, text.as_bytes().to_vec()); + let tree = tree.build(store).unwrap(); + store + .write_commit(&CommitInfo { + tree, + parents, + author: signature(), + committer: signature(), + extra_headers: Vec::new(), + message: text.as_bytes().to_vec(), + }) + .unwrap() +} +fn fixture() -> (MemoryStore, Oid, Oid, String) { + let mut store = MemoryStore::new(); + let a = source(&mut store, "base", vec![]); + let b = source(&mut store, "work", vec![a.clone()]); + let plan = format!("onto={a}\ncommitter=Replay 99 +0000\npick={b}\nbranch=feature\nbranch=work\n"); + let mut scope = TreeBuilder::from(None); + scope.put_oid("00-work", Mode::Commit, b.clone()); + scope.put("00.base", Mode::Blob, a.encode_line()); + scope.put("notes", Mode::Blob, b"ordinary notes\n".to_vec()); + scope.put("2026.md", Mode::Blob, b"dated notes\n".to_vec()); + scope.put("2026-notes.md", Mode::Blob, b"prefixed notes\n".to_vec()); + scope.put( + "2026-archive/file", + Mode::Blob, + b"archived notes\n".to_vec(), + ); + scope.put( + "2026.base/file", + Mode::Blob, + b"ordinary directory\n".to_vec(), + ); + scope.put("rebase/plan", Mode::Blob, plan.as_bytes().to_vec()); + let scope = scope.build(&mut store).unwrap(); + (store, scope, b, plan) +} + +#[test] +fn completes_from_scoped_tree_preserves_ordinary_files_and_removes_rebase() { + let (mut store, input, b, plan) = fixture(); + let proposal = propose(&mut store, &input).unwrap(); + let output = Snapshot::new(&store, proposal.tree); + assert_eq!(output.entry("00-feature").unwrap().unwrap().oid, b); + assert_eq!(output.entry("01-work").unwrap().unwrap().oid, b); + assert_eq!(output.read("01.base").unwrap().unwrap(), b.encode_line()); + assert_eq!(output.read("notes").unwrap().unwrap(), b"ordinary notes\n"); + assert_eq!(output.read("2026.md").unwrap().unwrap(), b"dated notes\n"); + assert_eq!( + output.read("2026-notes.md").unwrap().unwrap(), + b"prefixed notes\n" + ); + assert_eq!( + output.read("2026-archive/file").unwrap().unwrap(), + b"archived notes\n" + ); + assert_eq!( + output.read("2026.base/file").unwrap().unwrap(), + b"ordinary directory\n" + ); + assert!(!output.exists("00-work").unwrap()); + assert!(!output.exists("rebase").unwrap()); + assert!(proposal.report.contains(&plan)); + assert!(Snapshot::new(&store, input).exists("rebase/plan").unwrap()); +} + +#[test] +fn conflict_installs_only_draft_and_report_without_progress_or_output_layers() { + let (mut store, input, b, plan) = fixture(); + let parent = store.read_commit(&b).unwrap().parents[0].clone(); + let draft = source( + &mut store, + "<<<<<<< ours\n=======\n>>>>>>> theirs\n", + vec![parent.clone()], + ); + let outcome = rebase_plan::Outcome { + layers: vec![rebase_plan::Layer { + name: "00-new".into(), + tip: parent.clone(), + base: parent.clone(), + }], + conflict: Some(rebase_plan::Conflict { + line: 4, + parent: parent.clone(), + draft: draft.clone(), + report: b"native report\0stages\n".to_vec(), + }), + }; + let proposal = apply(&mut store, &input, &plan, outcome).unwrap(); + let output = Snapshot::new(&store, proposal.tree); + assert_eq!(output.entry("00-work").unwrap().unwrap().oid, b); + assert!(!output.exists("00-new").unwrap()); + assert!(!output.exists("rebase/stack").unwrap()); + assert_eq!( + output.read("rebase/plan").unwrap().unwrap(), + plan.as_bytes() + ); + assert_eq!(output.entry("rebase/work").unwrap().unwrap().oid, draft); + assert_eq!( + output.read("rebase/conflicts").unwrap().unwrap(), + b"native report\0stages\n" + ); + assert!(proposal.report.contains(&format!("pick={parent}..R"))); +} + +#[test] +fn retries_ignore_obsolete_draft_and_preserve_current_scope_files() { + let (mut store, input, b, _) = fixture(); + let mut edited = TreeBuilder::from(Some(input)); + edited.put_oid("rebase/work", Mode::Commit, b); + edited.put("rebase/conflicts", Mode::Blob, b"old report".to_vec()); + edited.put("notes", Mode::Blob, b"current notes".to_vec()); + let edited = edited.build(&mut store).unwrap(); + let first = propose(&mut store, &edited).unwrap(); + let second = propose(&mut store, &edited).unwrap(); + assert_eq!(first.tree, second.tree); + assert_eq!(first.report, second.report); + let output = Snapshot::new(&store, first.tree); + assert_eq!(output.read("notes").unwrap().unwrap(), b"current notes"); + assert!(!output.exists("rebase").unwrap()); +} + +#[test] +fn messages_are_scoped_regular_files_and_read_as_literal_bytes() { + let (mut store, input, b, plan) = fixture(); + let mut changed = TreeBuilder::from(Some(input)); + changed.put( + "rebase/plan", + Mode::Blob, + plan.replace("branch=feature", "message=messages/feature\nbranch=feature") + .into_bytes(), + ); + changed.put("messages/feature", Mode::Blob, b" literal\n\n".to_vec()); + let changed = changed.build(&mut store).unwrap(); + let proposal = propose(&mut store, &changed).unwrap(); + let tip = Snapshot::new(&store, proposal.tree) + .entry("00-feature") + .unwrap() + .unwrap() + .oid; + let commit = store.read_commit(&tip).unwrap(); + assert_eq!(commit.message, b" literal\n\n"); + assert_eq!(commit.parents, store.read_commit(&b).unwrap().parents); + assert_eq!(commit.committer.time, 99); + assert!(blob(&store, &changed, "messages") + .unwrap_err() + .contains("regular file")); +} + +#[test] +fn plan_is_required_but_previous_stack_entries_are_not() { + let (mut store, input, b, _) = fixture(); + let mut changed = TreeBuilder::from(Some(input)); + changed.delete("00-work"); + changed.delete("00.base"); + let changed = changed.build(&mut store).unwrap(); + let proposal = propose(&mut store, &changed).unwrap(); + assert_eq!( + Snapshot::new(&store, proposal.tree) + .entry("00-feature") + .unwrap() + .unwrap() + .oid, + b + ); + let mut missing = TreeBuilder::from(Some(changed)); + missing.delete("rebase"); + let missing = missing.build(&mut store).unwrap(); + assert!(propose(&mut store, &missing) + .unwrap_err() + .contains("missing file rebase/plan")); +} + +#[test] +fn completion_rejects_output_names_that_collide_with_ordinary_entries() { + for path in ["00-feature", "00-feature/file", "01.base/file"] { + let (mut store, input, _, _) = fixture(); + let mut changed = TreeBuilder::from(Some(input)); + changed.put(path, Mode::Blob, b"keep me".to_vec()); + let changed = changed.build(&mut store).unwrap(); + let error = propose(&mut store, &changed).unwrap_err(); + assert!( + error.contains("would replace an ordinary stack file"), + "{error}" + ); + assert_eq!( + Snapshot::new(&store, changed).read(path).unwrap().unwrap(), + b"keep me" + ); + } +} diff --git a/std/llm-step/src/source_trees.rs b/std/llm-step/src/source_trees.rs index 1e099681..bc34f91f 100644 --- a/std/llm-step/src/source_trees.rs +++ b/std/llm-step/src/source_trees.rs @@ -6,21 +6,21 @@ Conversation filesystem: - File tools, grep, and bash start at the conversation root. Source trees are commit-valued entries that appear as directories. Keep memories, notes, and skills outside code directories. Conversation-root .caos is readable protocol metadata; do not edit it. - When the user authorizes a small improvement without prescribing the exact edit, choose a clear, low-risk change and proceed. Ask only when missing information prevents useful work; do not ask the user to choose a typo you have already identified. - Organize source trees yourself with ordinary file operations. Users describe the desired changes and review structure; do not ask them to prescribe copies, renames, or bookkeeping. Use the simplest layout that fits the task. -- For reviewable changes, preserve the starting commit at /00-base before editing /01-change. Keep imports separate from feature work at imports//base. Imports only use existing commits. A local Git checkout root accepts a hash or ref; omitting it imports HEAD only when the checkout is clean. Dirty checkouts require committing changes first or explicitly choosing a revision that excludes them. Use import_source for HTTPS repositories: a branch, full ref, full commit hash, or the remote default branch. Plain directories and individual files cannot be imported. No publishing destination is needed to import or start work. Preserve imports unchanged; copy an imported gitlink to /00-base and /01-change with cp -a before editing. Never overwrite an existing base or label an already-edited snapshot as the original base; use the supplied import commit to recover it when necessary. -- Bash needs conversation-relative paths declared for existing content. Use mv and cp -a so commit identity survives. For example, with paths ["imports/caos"]: mkdir -p feature; cp -a imports/caos/base feature/00-base; cp -a imports/caos/base feature/01-change. To reference a known commit from CAOS, use caos get-hash /cas/source, then ln -s /cas/source ; the next tool call exposes that gitlink as a directory. Host paths belong to the client; HTTPS imports use import_source. -- Each accepted code edit, including a bash deletion, records a child commit automatically. There is no separate staging or conflict-resolution registration step. Work directly in a named boundary such as 01-parser. To start the next PR, cp -a that entry to 02-errors and edit the copy, preserving the earlier boundary. Sibling gitlinks sort by filename: the first is the base and each later entry is a review boundary. Numbering is a naming convention, not a required schema. No entry has a special working or sealed state. Directory order does not merge histories. +- For reviewable changes, keep imports unchanged and start feature/00-work from the imported gitlink, with its full commit id in feature/00.base. Numbered layers have matching NN.base files recording their original predecessor. Names such as work are ordinary names, not sealed or dirty states. Other feature files are allowed. +- Bash needs conversation-relative paths declared for existing content. Use mv and cp -a so commit identity survives. Copy an imported gitlink into a feature directory and write its returned commit id into the matching NN.base file. To reference a known commit from CAOS, use caos get-hash /cas/source, then ln -s /cas/source . Host paths belong to the client; HTTPS imports use import_source. +- Each accepted code edit creates a child commit automatically. Prepare intentional layers with the repository git-rebase-i tool: write feature/rebase/plan, then run_tool at the tool path with scope="feature". The plan begins onto= and committer=, then uses pick=, message=, and branch=. A range makes one commit from the net change, avoiding thousands of tool-call commits. Branch instructions assign consecutive numbers from 00; a second branch can start the next work at the same tip. Use tool_help and design/agent-rebase.md for details. - Delegate bounded tasks with explicit code paths. Children have isolated content and their own transcripts. The parent waits, harvests changes, and checks the result; it owns review-boundary organization unless delegated explicitly. For a PR stack, integrate one coherent change at a time and preserve a boundary after each. Concurrent edits are reconciled on harvest; on conflict, inspect the retained proposal and resolve it instead of overwriting parent work. - Before integrating a publication base, compare the intended feature change with the full source-versus-destination difference. Merging upstream preserves all existing branch changes; rebasing a whole branch may replay inherited changes too. Transplanting only the requested edit onto a new base is a separate operation. If a small task would publish unrelated inherited work, explain that scope and ask whether to retain the branch or transplant the edit. Do not claim that targeting an older upstream isolates the edit. Preserve existing snapshots when making a new starting point. -- Resolve source-tree conflicts by editing affected files and clearing their ledger entries. Saving an edited source tree removes an empty .caos/conflicts ledger and prunes its .caos directory if empty. Unresolved entries and other metadata stay intact. Do not recreate a removed ledger to register resolution. This source-tree ledger is distinct from protected conversation-root .caos. -- Preparing, delegating, merging, testing, and organizing a PR stack requires no publishing destination. Preserve the imported starting commit at 00-base. Finish the requested work without asking for repository publication settings. +- Replay conflicts produce feature/rebase/work and feature/rebase/conflicts. Edit and test the draft, replace the failed pick with pick=H..R using the reported output parent H and resolved draft commit R, then run the plan again. Each invocation starts from the beginning with deterministic commit ids. Delete rebase/ to abort. Ordinary two-parent merge conflicts still use the source tree .caos/conflicts ledger; resolve its paths and remove their records. +- Preparing, delegating, testing, and organizing a stack requires no publication destination. Preserve imports and recorded bases; finish the requested work without asking for repository publication settings. - When branch publication is requested, use publish_source with an explicit gitlink, HTTPS repository and branch. Inspect the complete PR diff, incorporate the chosen base and prior stack boundary, resolve conflicts and run checks first. No publication policy file or local branch database is needed. Import provenance can identify the repository and default base; ask only when ambiguous. - If a tool fails, report the observed error and uncertainty; do not invent storage behavior or claim success from a failed check. Use available repository tools for relevant tests; describe a specific missing capability rather than assuming workers cannot run tests or reach a server. - Git tools require the target commit-entry path explicitly. Invoke repository tools with run_tool at a conversation-relative path. UI selection never changes your execution context. -- Repository tools are not listed for you. A tool is any directory carrying a .caos-expr that binds a help, and each repository documents its own in AGENTS.md or its docs. Call tool_help with that conversation-relative path (for example feature/01-change/caos-tools/test) to get its parameters, then run_tool with the same path and those arguments under `arguments`. tool_help reads the tree only and never builds anything, so prefer it over guessing parameters from prose; the docs can be stale, the help cannot. +- Repository tools are not listed for you. A tool is any directory carrying a .caos-expr that binds a help, and each repository documents its own in AGENTS.md or its docs. Call tool_help with that conversation-relative path (for example feature/01-change/caos-tools/test) to get its parameters, then run_tool with the same path and those arguments under `arguments`. tool_help evaluates the tool path and can build its image; use it rather than guessing parameters from prose. - Use import_source(source="https://github.com/owner/repo.git", revision="main", into="imports/repo/main-2") for remote code. Omit revision for the default branch. Public imports need no token; private imports use the granted github-token secret. Each new call observes the remote; retries of a persisted call keep its pinned commit. Choose an unused path and preserve the imported snapshot. The tool returns a full commit hash and records provenance in its .source.json sibling. It does not merge code. -- For origin/main, read the selected source's import provenance, use that repository URL and revision main with import_source, then merge the returned commit into the intended source tree. If provenance is ambiguous, use an explicit repository. Remote names are not local ref snapshots. +- For origin/main, use the selected source import provenance to import a fresh main commit. For a stack, use that commit as onto and replay each layer from its recorded base. Ordinary source merges can use the merge tool. Remote names are not local ref snapshots. Client actions (give these instructions to the user, not to bash or run_tool): - When a task needs a client action, give the exact TUI command or keys and concrete conversation path. Do not just say "ask to import", "trigger an import", or "publish it". Use supplied paths/URLs; ask only for missing information. Do not claim a client operation succeeded without its result. Subagents report client needs to their parent. diff --git a/tests/llm-rebase/.caos-expr b/tests/llm-rebase/.caos-expr new file mode 100644 index 00000000..6bc7780f --- /dev/null +++ b/tests/llm-rebase/.caos-expr @@ -0,0 +1 @@ +curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh --llm-step:@=DEEP-DEPS/llm-step --stub:@=DEEP-DEPS/llm-stub --tool:@=DEEP-DEPS/llm-test-tool --common:@=DEEP-DEPS/llm-test/worker-common.sh --stack-worker:@=DEEP-DEPS/git-stack --rebase-expr:@=DEEP-DEPS/rebase-tool/.caos-expr diff --git a/tests/llm-rebase/DEPS b/tests/llm-rebase/DEPS new file mode 100644 index 00000000..9032e42c --- /dev/null +++ b/tests/llm-rebase/DEPS @@ -0,0 +1,8 @@ +# DEPS format and dependency-closure rules are documented in design/caos-expr.md +../../std/llm-step llm-step +../../std/llm-stub llm-stub +../../std/llm-test llm-test +../../std/llm-test-tool llm-test-tool +../../std/git-stack git-stack +../../std/git-rebase-i rebase-tool +../../dev/worker-test worker-test diff --git a/tests/llm-rebase/worker.sh b/tests/llm-rebase/worker.sh new file mode 100755 index 00000000..929e0aab --- /dev/null +++ b/tests/llm-rebase/worker.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# Stateless scoped replay through the real agent handler and server object APIs +# shellcheck disable=SC1091,SC2034,SC2154 +set -euo pipefail +caos get /cas/args/common +# shellcheck disable=SC1090 +source /cas/args/common +llm_test_setup + +stage "source history and a conflicting new base" +mkdir -p /tmp/source +printf 'original\n' > /tmp/source/file +at=$(publish_tree /tmp/source /cas/a-tree "base tree") +a=$(mint_commit /cas/a "$at" "original ($SALT)") +printf 'intermediate\n' > /tmp/source/file +xt=$(publish_tree /tmp/source /cas/x-tree "intermediate tree") +x=$(mint_commit /cas/x "$xt" "tool call" "$a") +printf 'feature\n' > /tmp/source/file +bt=$(publish_tree /tmp/source /cas/b-tree "working tree") +b=$(mint_commit /cas/b "$bt" "another tool call" "$x") +printf 'second layer\n' > /tmp/source/second +ct=$(publish_tree /tmp/source /cas/c-tree "upper layer tree") +c=$(mint_commit /cas/c "$ct" "upper tool call" "$b") +rm /tmp/source/second +printf 'upstream\n' > /tmp/source/file +ht=$(publish_tree /tmp/source /cas/h-tree "upstream tree") +h=$(mint_commit /cas/h "$ht" "new upstream" "$a") +fixture_ref="refs/heads/llm-rebase-fixture-$(date +%s%N)-$$" +fetch_code "$h" "fetching fixture tip" +git push -q caos "$h:$fixture_ref" +trap 'git push -q caos ":$fixture_ref" >/dev/null 2>&1 || true; llm_test_cleanup' EXIT + +stage "public repository tool with the evaluated worker pinned" +stack_worker=$(caos hash /cas/args/stack-worker) +assert_oid "$stack_worker" "stack worker image" +base_binding='--base:@=DEEP-DEPS/git-stack' +caos get /cas/args/rebase-expr +expression=$(cat /cas/args/rebase-expr) +pinned=${expression/"$base_binding"/"--base:hash=$stack_worker"} +[ "$pinned" != "$expression" ] || fail "replay wrapper lacks the shared worker binding" +mkdir -p /tmp/repository-tools/git-rebase-i +printf '%s\n' "$pinned" > /tmp/repository-tools/git-rebase-i/.caos-expr +tools_tree=$(publish_tree /tmp/repository-tools /cas/repository-tools "repository tools") + +committer='Replay 1700000001 +0000' +plan=$(printf 'onto=%s\ncommitter=%s\npick=%s..%s\nmessage=messages/first\nbranch=feature\npick=%s\nmessage=messages/second\nbranch=second\n' "$h" "$committer" "$a" "$b" "$c") +promotion_plan=$(printf 'onto=%s\ncommitter=%s\npick=%s..%s\nmessage=message\nbranch=promoted\nbranch=work\n' "$a" "$committer" "$a" "$b") +setup_cmd=$(cat < feature/00.base +printf '%s\n' $b > feature/01.base +printf '%s\n' $a > promotion/00.base +printf 'preserve notes\n' > feature/notes +printf 'preserve promotion notes\n' > promotion/notes +EOF +) +promotion_message=$' promoted subject \n\nkeep message whitespace\n\n' +mkdir -p /tmp/stub +jq -n --arg cmd "$setup_cmd" --arg plan "$plan" --arg promotion_plan "$promotion_plan" --arg message "$promotion_message" '{content:[ + {type:"tool_use",id:"setup",name:"bash",input:{cmd:$cmd,paths:[]}}, + {type:"tool_use",id:"plan",name:"write",input:{"file-path":"feature/rebase/plan",content:$plan}}, + {type:"tool_use",id:"promotion-plan",name:"write",input:{"file-path":"promotion/rebase/plan",content:$promotion_plan}}, + {type:"tool_use",id:"promotion-message",name:"write",input:{"file-path":"promotion/message",content:$message}}, + {type:"tool_use",id:"first-message",name:"write",input:{"file-path":"feature/messages/first",content:"first feature\n"}}, + {type:"tool_use",id:"second-message",name:"write",input:{"file-path":"feature/messages/second",content:"second feature\n"}} +],stop_reason:"tool_use"}' > /tmp/stub/response-1.json +cat > /tmp/stub/response-2.json <<'JSON' +{"content":[ + {"type":"tool_use","id":"help","name":"tool_help","input":{"path":"tools/git-rebase-i"}}, + {"type":"tool_use","id":"promote","name":"run_tool","input":{"path":"tools/git-rebase-i","scope":"promotion","arguments":{}}}, + {"type":"tool_use","id":"start","name":"run_tool","input":{"path":"tools/git-rebase-i","scope":"feature","arguments":{}}} +],"stop_reason":"tool_use"} +JSON +cat > /tmp/stub/response-3.json <<'JSON' +{"content":[ + {"type":"tool_use","id":"report","name":"read","input":{"file-path":"feature/rebase/conflicts"}}, + {"type":"tool_use","id":"draft","name":"read","input":{"file-path":"feature/rebase/work/file"}}, + {"type":"tool_use","id":"resolve","name":"write","input":{"file-path":"feature/rebase/work/file","content":"resolved\n"}} +],"stop_reason":"tool_use"} +JSON +# The resolved gitlink's commit R is only known after the edit. The agent changes +# the failed instruction to H..R; no saved progress or continue operation exists. +rewrite_cmd=$(cat < feature/rebase/plan +EOF +) +jq -n --arg cmd "$rewrite_cmd" '{content:[ + {type:"tool_use",id:"rewrite-plan",name:"bash",input:{cmd:$cmd,paths:["feature/rebase/work","feature/rebase/plan"]}} +],stop_reason:"tool_use"}' > /tmp/stub/response-4.json +cat > /tmp/stub/response-5.json <<'JSON' +{"content":[{"type":"tool_use","id":"replay","name":"run_tool","input":{"path":"tools/git-rebase-i","scope":"feature","arguments":{}}}],"stop_reason":"tool_use"} +JSON +printf '%s\n' '{"content":[{"type":"text","text":"done"}],"stop_reason":"end_turn"}' > /tmp/stub/response-6.json +start_stub /tmp/stub +new_llm_conversation llm-rebase "$STUB_PORT" - "Use the repository replay tool to promote work and restack the supplied feature." +dispatch_turn "Promote the work and restack the feature. ($SALT)" +wait_turn 600 + +stage "a short replay plan promotes work and starts the next layer" +promoted=$(source_tree_commit "$head" promotion/00-promoted) +next_work=$(source_tree_commit "$head" promotion/01-work) +fetch_code "$promoted" "fetching promoted source history" +[ "$promoted" != "$b" ] || fail "promotion retained the raw work commit" +[ "$(git rev-parse "$promoted^{tree}")" = "$bt" ] || fail "promotion changed the work tree" +[ "$(git rev-parse "$promoted^")" = "$a" ] || fail "promotion did not squash onto its recorded base" +[ "$(git rev-list --count "$a..$promoted")" = 1 ] || fail "tool-call history remains in promoted layer" +[ "$next_work" = "$promoted" ] || fail "next work does not point to promoted commit" +[ "$(record "$head" promotion/00.base)" = "$a" ] || fail "promotion changed its recorded base" +[ "$(record "$head" promotion/01.base)" = "$promoted" ] || fail "next work has the wrong base" +[ "$(record "$head" promotion/notes)" = 'preserve promotion notes' ] || fail "promotion lost ordinary files" +if git cat-file -e "$head:promotion/00-work" 2>/dev/null; then fail "promoted work name remains"; fi +printf '%s' "$promotion_message" > /tmp/expected-message +git cat-file commit "$promoted" | sed '1,/^$/d' > /tmp/promoted-message +cmp -s /tmp/expected-message /tmp/promoted-message || fail "promotion changed the literal message bytes" + +stage "replayed resolution retains source changes and intentional history" +first=$(source_tree_commit "$head" feature/00-feature) +second=$(source_tree_commit "$head" feature/01-second) +fetch_code "$second" "fetching finished source history" +[ "$(git rev-parse "$first^")" = "$h" ] || fail "first output has the wrong parent" +[ "$(git rev-parse "$second^")" = "$first" ] || fail "second output has the wrong parent" +[ "$(git rev-list --count "$h..$second")" = 2 ] || fail "draft tool-call commits leaked into output history" +[ "$(git show "$second:file")" = resolved ] || fail "resolution was lost while replaying the upper commit" +[ "$(git show "$second:second")" = 'second layer' ] || fail "upper commit was not applied" +[ "$(git show -s --format=%B "$first")" = 'first feature' ] || fail "wrong first message" +[ "$(git show -s --format=%B "$second")" = 'second feature' ] || fail "wrong second message" +[ "$(record "$head" feature/00.base)" = "$h" ] || fail "wrong first base" +[ "$(record "$head" feature/01.base)" = "$first" ] || fail "wrong second base" +[ "$(record "$head" feature/notes)" = 'preserve notes' ] || fail "ordinary feature files were lost" +if git cat-file -e "$head:feature/rebase" 2>/dev/null; then fail "completed replay directory remains"; fi +if git cat-file -e "$head:feature/00-work" 2>/dev/null; then fail "old layer name remains"; fi +grep -qF 'CONFLICT' /tmp/stub/request-4.json || fail "native conflict report was not readable" +grep -qF '<<<<<<<' /tmp/stub/request-4.json || fail "draft omitted Git conflict markers" +jq -e '.tools | any(.name == "run_tool") and all(.name != "git-rebase-i")' /tmp/stub/request-1.json >/dev/null || fail "replay must be a repository tool" +$TOOL tools --repo /tmp/repo --head "$head" --request "$request" > /tmp/rebase-tools.jsonl +jq -s -e 'all(.[]; .status == "complete")' /tmp/rebase-tools.jsonl >/dev/null || fail "a replay tool call failed" +for response in /tmp/stub/request-{2,3,4,5,6}.json; do + jq -e '[.messages[].content | select(type == "array") | .[] | select(.type == "tool_result")] | all(.is_error != true)' "$response" >/dev/null || fail "agent received an error result" +done +assert_spine "$head" +pass llm-rebase