fabro fork subcommand (#9)

This PR adds a new `fabro fork` subcommand that creates a new run
branching from an existing run at a specific checkpoint, without
modifying the original run. This is a non-destructive alternative to
`fabro rewind` — instead of moving branch refs backward and losing later
checkpoint history, fork preserves the source run entirely and creates
fresh run and metadata branches for the new run.

The implementation heavily reuses existing infrastructure from
`rewind.rs` (timeline building, target resolution, parallel map loading,
prefix-based run ID lookup) and follows the same CLI patterns. The core
`execute_fork` function generates a new ULID, creates a run branch ref
pointing at the target checkpoint's commit, then builds a new metadata
branch containing an updated manifest (with new run ID and branch name),
the original graph, and the checkpoint state from the target commit. It
supports the same target syntax as rewind (`@N`, `node_name`,
`node_name@N`), defaults to the latest checkpoint when no target is
specified, and optionally pushes new branches to the remote.

The PR also makes `load_parallel_map` public in `rewind.rs` so fork can
reuse it, and includes five tests covering run branch creation, metadata
branch correctness, preservation of the original run, default-to-latest
behavior, and forking at a specific ordinal.

### Fabro Details

<details>
<summary>Ran 7 stages in 15m 15s for $4.39</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $1.94 | 0 |
| simplify | 0s | $2.44 | 0 |
| verify | 0s | – | 0 |
| **Total** | **15m 15s** | **$4.39** | **0** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>

```dot
digraph ImplementAndSimplify {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { backend: api; model: claude-opus-4-6;}
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
    simplify          [label="Simplify", prompt="@prompts/simplify.md"]
    verify            [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=success"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=success"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=success"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify -> verify
    verify -> exit  [condition="outcome=success"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
brynary-fabro[bot] 2026-03-15 19:54:24 -04:00 committed by GitHub
parent c687c29426
commit d6f1bce7ef
4 changed files with 421 additions and 1 deletions

View file

@ -124,6 +124,8 @@ enum Command {
},
/// Rewind a workflow run to an earlier checkpoint
Rewind(fabro_workflows::cli::rewind::RewindArgs),
/// Fork a workflow run from an earlier checkpoint into a new run
Fork(fabro_workflows::cli::fork::ForkArgs),
/// Workflow operations
Workflow {
#[command(subcommand)]
@ -409,6 +411,7 @@ async fn main_inner() -> (String, Result<()>) {
PrCommand::Close(_) => "pr close",
},
Command::Rewind(_) => "rewind",
Command::Fork(_) => "fork",
Command::Workflow { command } => match command {
WorkflowCommand::List(_) => "workflow list",
WorkflowCommand::Create(_) => "workflow create",
@ -731,6 +734,10 @@ async fn main_inner() -> (String, Result<()>) {
let styles = fabro_util::terminal::Styles::detect_stderr();
fabro_workflows::cli::rewind::rewind_command(&args, &styles)?;
}
Command::Fork(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
fabro_workflows::cli::fork::fork_command(&args, &styles)?;
}
Command::Workflow { command } => match command {
WorkflowCommand::List(args) => {
fabro_workflows::cli::workflow::workflow_list_command(&args)?;

View file

@ -0,0 +1,412 @@
use anyhow::{Context, Result};
use clap::Args;
use fabro_git_storage::branchstore::BranchStore;
use fabro_git_storage::gitobj::Store;
use fabro_util::terminal::Styles;
use git2::{Oid, Repository, Signature};
use crate::git::MetadataStore;
use crate::manifest::Manifest;
use super::rewind::{
build_timeline, find_run_id_by_prefix, load_parallel_map, parse_target, print_timeline,
resolve_target, TimelineEntry,
};
/// Fork a workflow run from an earlier checkpoint into a new run.
#[derive(Debug, Args)]
pub struct ForkArgs {
/// Run ID (or unambiguous prefix)
pub run_id: String,
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest)
pub target: Option<String>,
/// Show the checkpoint timeline instead of forking
#[arg(long)]
pub list: bool,
/// Skip pushing new branches to the remote
#[arg(long)]
pub no_push: bool,
}
/// Entry point for `fabro fork`.
pub fn fork_command(args: &ForkArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let run_id = find_run_id_by_prefix(&repo, &args.run_id)?;
let store = Store::new(repo);
let timeline = build_timeline(&store, &run_id)?;
if args.list {
let parallel_map = load_parallel_map(&store, &run_id);
print_timeline(&timeline, &parallel_map, styles);
return Ok(());
}
let entry = if let Some(target_str) = &args.target {
let target = parse_target(target_str)?;
let parallel_map = load_parallel_map(&store, &run_id);
resolve_target(&timeline, &target, &parallel_map)?
} else {
timeline
.last()
.ok_or_else(|| anyhow::anyhow!("no checkpoints found for run {run_id}"))?
};
let new_run_id = execute_fork(&store, &run_id, entry, !args.no_push)?;
eprintln!(
"\nForked run {} -> {}",
&run_id[..8.min(run_id.len())],
&new_run_id[..8.min(new_run_id.len())]
);
eprintln!(
"To resume: fabro run --run-branch {}{}",
crate::git::RUN_BRANCH_PREFIX,
new_run_id
);
Ok(())
}
/// Create a new run that branches from an existing run at a specific checkpoint.
///
/// Returns the new run ID.
pub fn execute_fork(
store: &Store,
source_run_id: &str,
entry: &TimelineEntry,
push: bool,
) -> Result<String> {
let new_run_id = ulid::Ulid::new().to_string();
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
// 1. Create new run branch pointing at the target checkpoint's run commit
let new_run_branch = format!("{}{new_run_id}", crate::git::RUN_BRANCH_PREFIX);
match &entry.run_commit_sha {
Some(sha) => {
let oid =
Oid::from_str(sha).with_context(|| format!("invalid run commit SHA: {sha}"))?;
store
.update_ref(&new_run_branch, oid)
.map_err(|e| anyhow::anyhow!("failed to create run branch ref: {e}"))?;
}
None => {
anyhow::bail!(
"checkpoint @{} has no git_commit_sha; cannot fork",
entry.ordinal
);
}
}
// 2. Create new metadata branch
let source_meta_branch = MetadataStore::branch_name(source_run_id);
let new_meta_branch = MetadataStore::branch_name(&new_run_id);
let source_bs = BranchStore::new(store, &source_meta_branch, &sig);
let new_bs = BranchStore::new(store, &new_meta_branch, &sig);
new_bs
.ensure_branch()
.map_err(|e| anyhow::anyhow!("failed to create metadata branch: {e}"))?;
// Read manifest, graph, and sandbox from source in a single tree lookup
let source_entries = source_bs
.read_entries(&["manifest.json", "graph.fabro", "sandbox.json"])
.map_err(|e| anyhow::anyhow!("failed to read source metadata: {e}"))?;
let mut manifest_bytes = None;
let mut graph_bytes = None;
let mut sandbox_bytes = None;
for (path, data) in source_entries {
match path {
"manifest.json" => manifest_bytes = Some(data),
"graph.fabro" => graph_bytes = Some(data),
"sandbox.json" => sandbox_bytes = Some(data),
_ => {}
}
}
let manifest_bytes =
manifest_bytes.ok_or_else(|| anyhow::anyhow!("source run has no manifest.json"))?;
let graph_bytes =
graph_bytes.ok_or_else(|| anyhow::anyhow!("source run has no graph.fabro"))?;
let mut manifest: Manifest =
serde_json::from_slice(&manifest_bytes).context("failed to parse source manifest.json")?;
manifest.run_id = new_run_id.clone();
manifest.run_branch = Some(new_run_branch.clone());
manifest.start_time = chrono::Utc::now();
let new_manifest_bytes =
serde_json::to_vec_pretty(&manifest).context("failed to serialize new manifest")?;
// Read checkpoint from the target metadata commit (not branch tip)
let checkpoint_bytes = store
.read_blob_at(entry.metadata_commit_oid, "checkpoint.json")
.map_err(|e| anyhow::anyhow!("failed to read checkpoint blob: {e}"))?
.ok_or_else(|| {
anyhow::anyhow!(
"no checkpoint.json at metadata commit {}",
entry.metadata_commit_oid
)
})?;
// Write all entries to the new metadata branch in a single commit
let mut file_entries: Vec<(&str, &[u8])> = vec![
("manifest.json", &new_manifest_bytes),
("graph.fabro", &graph_bytes),
("checkpoint.json", &checkpoint_bytes),
];
if let Some(ref sandbox) = sandbox_bytes {
file_entries.push(("sandbox.json", sandbox));
}
let commit_msg = format!("fork from {} @{}", source_run_id, entry.ordinal);
new_bs
.write_entries(&file_entries, &commit_msg)
.map_err(|e| anyhow::anyhow!("failed to write metadata entries: {e}"))?;
// 3. Optionally push both new branches to origin
if push {
let repo_path = store
.repo()
.workdir()
.or_else(|| store.repo().path().parent())
.unwrap_or(store.repo().path());
// Check if the source run branch has a remote tracking ref (indicating we use a remote)
let source_run_branch = format!("{}{source_run_id}", crate::git::RUN_BRANCH_PREFIX);
let remote_ref = format!("refs/remotes/origin/{source_run_branch}");
let has_remote_tracking = store.repo().find_reference(&remote_ref).is_ok();
if has_remote_tracking {
eprintln!("Pushing new branches to origin...");
// Push run branch
let run_refspec = format!("refs/heads/{new_run_branch}:refs/heads/{new_run_branch}");
crate::git::push_branch(repo_path, "origin", &run_refspec)
.map_err(|e| anyhow::anyhow!("failed to push run branch: {e}"))?;
// Push metadata branch
let meta_refspec = format!("refs/heads/{new_meta_branch}:refs/heads/{new_meta_branch}");
crate::git::push_branch(repo_path, "origin", &meta_refspec)
.map_err(|e| anyhow::anyhow!("failed to push metadata branch: {e}"))?;
eprintln!("Remote refs updated.");
}
}
Ok(new_run_id)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn temp_repo() -> (tempfile::TempDir, Store) {
let dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
(dir, Store::new(repo))
}
fn test_sig() -> Signature<'static> {
Signature::now("Test", "test@example.com").unwrap()
}
fn make_checkpoint_json(current_node: &str, visit: usize, git_sha: Option<&str>) -> Vec<u8> {
let mut node_visits = HashMap::new();
node_visits.insert(current_node.to_string(), visit);
let cp = serde_json::json!({
"timestamp": "2025-01-01T00:00:00Z",
"current_node": current_node,
"completed_nodes": [current_node],
"node_retries": {},
"context_values": {},
"logs": [],
"node_visits": node_visits,
"git_commit_sha": git_sha,
});
serde_json::to_vec(&cp).unwrap()
}
fn make_manifest_json(run_id: &str) -> Vec<u8> {
let manifest = serde_json::json!({
"run_id": run_id,
"workflow_name": "test_workflow",
"goal": "Test goal",
"start_time": "2025-01-01T00:00:00Z",
"node_count": 3,
"edge_count": 2,
});
serde_json::to_vec_pretty(&manifest).unwrap()
}
/// Set up a source run with the given number of checkpoints.
/// Returns (run_id, vec of run commit OIDs).
fn setup_source_run(store: &Store, run_id: &str, nodes: &[&str]) -> Vec<Oid> {
let sig = test_sig();
// Create run branch with commits
let run_branch = format!("{}{run_id}", crate::git::RUN_BRANCH_PREFIX);
let empty_tree = store.write_empty_tree().unwrap();
let mut run_oids = Vec::new();
let mut parent: Option<Oid> = None;
for node in nodes {
let parents = match parent {
Some(p) => vec![p],
None => vec![],
};
let oid = store
.write_commit(
empty_tree,
&parents,
&format!("fabro({run_id}): {node} (completed)"),
&sig,
)
.unwrap();
store.update_ref(&run_branch, oid).unwrap();
run_oids.push(oid);
parent = Some(oid);
}
// Create metadata branch
let meta_branch = MetadataStore::branch_name(run_id);
let bs = BranchStore::new(store, &meta_branch, &sig);
bs.ensure_branch().unwrap();
// Write manifest and graph
let manifest = make_manifest_json(run_id);
let graph = b"digraph { start -> build -> test }";
bs.write_entries(
&[("manifest.json", &manifest), ("graph.fabro", graph)],
"init run",
)
.unwrap();
// Write checkpoint commits
for (i, node) in nodes.iter().enumerate() {
let cp = make_checkpoint_json(node, 1, Some(&run_oids[i].to_string()));
bs.write_entry("checkpoint.json", &cp, "checkpoint")
.unwrap();
}
run_oids
}
#[test]
fn fork_creates_new_run_branch() {
let (_dir, store) = temp_repo();
let run_oids = setup_source_run(&store, "source-run", &["start", "build"]);
let timeline = build_timeline(&store, "source-run").unwrap();
// Fork at @1 (start)
let entry = &timeline[0];
let new_run_id = execute_fork(&store, "source-run", entry, false).unwrap();
// Verify new run branch exists and points at the target run commit
let new_run_branch = format!("{}{new_run_id}", crate::git::RUN_BRANCH_PREFIX);
let resolved = store.resolve_ref(&new_run_branch).unwrap().unwrap();
assert_eq!(resolved, run_oids[0]);
}
#[test]
fn fork_creates_new_metadata_branch() {
let (_dir, store) = temp_repo();
setup_source_run(&store, "source-run", &["start", "build"]);
let timeline = build_timeline(&store, "source-run").unwrap();
let entry = &timeline[0]; // @1
let new_run_id = execute_fork(&store, "source-run", entry, false).unwrap();
// Verify new metadata branch exists
let new_meta_branch = MetadataStore::branch_name(&new_run_id);
let sig = test_sig();
let bs = BranchStore::new(&store, &new_meta_branch, &sig);
// Check manifest has new run_id
let manifest_bytes = bs.read_entry("manifest.json").unwrap().unwrap();
let manifest: Manifest = serde_json::from_slice(&manifest_bytes).unwrap();
assert_eq!(manifest.run_id, new_run_id);
assert_eq!(
manifest.run_branch.as_deref(),
Some(format!("{}{new_run_id}", crate::git::RUN_BRANCH_PREFIX).as_str())
);
// Check graph exists
let graph_bytes = bs.read_entry("graph.fabro").unwrap().unwrap();
assert_eq!(graph_bytes, b"digraph { start -> build -> test }");
// Check checkpoint matches target (@1 = start)
let cp_bytes = bs.read_entry("checkpoint.json").unwrap().unwrap();
let cp: serde_json::Value = serde_json::from_slice(&cp_bytes).unwrap();
assert_eq!(cp["current_node"], "start");
}
#[test]
fn fork_preserves_original_run() {
let (_dir, store) = temp_repo();
let run_oids = setup_source_run(&store, "source-run", &["start", "build", "test"]);
// Record original refs
let source_run_branch = format!("{}source-run", crate::git::RUN_BRANCH_PREFIX);
let source_meta_branch = MetadataStore::branch_name("source-run");
let original_run_ref = store.resolve_ref(&source_run_branch).unwrap().unwrap();
let original_meta_ref = store.resolve_ref(&source_meta_branch).unwrap().unwrap();
let timeline = build_timeline(&store, "source-run").unwrap();
let entry = &timeline[0]; // @1
execute_fork(&store, "source-run", entry, false).unwrap();
// Verify source branches are untouched
let after_run_ref = store.resolve_ref(&source_run_branch).unwrap().unwrap();
let after_meta_ref = store.resolve_ref(&source_meta_branch).unwrap().unwrap();
assert_eq!(original_run_ref, after_run_ref);
assert_eq!(original_meta_ref, after_meta_ref);
// Verify source run branch still points at the last commit (test)
assert_eq!(after_run_ref, run_oids[2]);
}
#[test]
fn fork_defaults_to_latest_checkpoint() {
let (_dir, store) = temp_repo();
let run_oids = setup_source_run(&store, "source-run", &["start", "build", "test"]);
let repo = store.repo();
let run_id = find_run_id_by_prefix(repo, "source-run").unwrap();
let timeline = build_timeline(&store, &run_id).unwrap();
// Default: fork from the last checkpoint
let entry = timeline.last().unwrap();
let new_run_id = execute_fork(&store, &run_id, entry, false).unwrap();
// Verify new run branch points at the last run commit (test)
let new_run_branch = format!("{}{new_run_id}", crate::git::RUN_BRANCH_PREFIX);
let resolved = store.resolve_ref(&new_run_branch).unwrap().unwrap();
assert_eq!(resolved, run_oids[2]);
}
#[test]
fn fork_at_specific_ordinal() {
let (_dir, store) = temp_repo();
let run_oids = setup_source_run(&store, "source-run", &["start", "build", "test"]);
let timeline = build_timeline(&store, "source-run").unwrap();
// Fork at @2 (build)
let target = parse_target("@2").unwrap();
let entry = resolve_target(&timeline, &target, &HashMap::new()).unwrap();
let new_run_id = execute_fork(&store, "source-run", entry, false).unwrap();
// Verify new run branch points at the second run commit (build)
let new_run_branch = format!("{}{new_run_id}", crate::git::RUN_BRANCH_PREFIX);
let resolved = store.resolve_ref(&new_run_branch).unwrap().unwrap();
assert_eq!(resolved, run_oids[1]);
}
}

View file

@ -2,6 +2,7 @@ pub mod backend;
pub mod cli_backend;
pub mod cp;
pub mod diff;
pub mod fork;
pub mod graph;
pub mod inspect;
pub mod logs;

View file

@ -472,7 +472,7 @@ pub fn rewind_command(args: &RewindArgs, styles: &Styles) -> Result<()> {
}
/// Load the graph from the metadata branch and build the parallel interior map.
fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
pub fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
let branch = MetadataStore::branch_name(run_id);
let sig = match Signature::now("Fabro", "noreply@fabro.sh") {
Ok(s) => s,