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
15 changes: 12 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
13 changes: 13 additions & 0 deletions std/git-rebase-i/.caos-expr
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
HELP=<<END
Replay rebase/plan within the selected stack directory without checking out its sources
The plan starts with onto=COMMIT and committer=Name <email> <unix-seconds> <+/-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
1 change: 1 addition & 0 deletions std/git-rebase-i/DEPS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
../git-stack git-stack
1 change: 1 addition & 0 deletions std/git-stack/.caos-expr
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions std/git-stack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions std/git-stack/DEPS
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions std/git-stack/src/main.rs
Original file line number Diff line number Diff line change
@@ -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)
}
144 changes: 144 additions & 0 deletions std/git-stack/src/rebase.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>, 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<Proposal, String> {
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<Proposal, String> {
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<CommitInfo, String> {
self.store.read_commit(oid).map_err(String::from)
}

fn read_message(&mut self, path: &str) -> Result<Vec<u8>, String> {
blob(self.store, self.input, path)
}

fn merge_trees(
&mut self,
base: &Oid,
ours: &Oid,
theirs: &Oid,
) -> Result<rebase_plan::Merge, String> {
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<Oid, String> {
self.store.write_commit(commit).map_err(String::from)
}
}

#[cfg(test)]
#[path = "rebase_tests.rs"]
mod tests;
Loading
Loading