From d0d2bf561048900853c1d7a561549294fc2f40c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 13 Nov 2026 20:39:33 +0100 Subject: [PATCH 1/5] feat(tree): show each splice's B, U, R and T and pending work at a pause A recursive pull is hard to follow: the drawings show its states, but a Rust test that pauses it could only print ad-hoc lines of refs and file contents. A paused pull now gives the splice tree as a table, in the drawings' terms: per splice the boundary B, synced commit U, rebuild R and upstream branch T, indented inside its parent, and what the pull did or still does for it ("pending: merge, after b/"). Commits are labeled like the drawings: by the names a test gives them, by the scenario label in their subject ("a1: ..."), or as name' for a rebuild of a named commit. A test reuses the step-by-step page's setup.bash and checks the table after each fetch, merge and the fold; the merge states match the page's drawings step-a to step-d. --- docs/10-contributing/01-development.md | 1 + src/execution.rs | 11 ++ src/lib.rs | 1 + src/merge.rs | 21 +++ src/pull.rs | 225 ++++++++++++++++++++++++ src/tree.rs | 228 +++++++++++++++++++++++++ 6 files changed, 487 insertions(+) create mode 100644 src/tree.rs diff --git a/docs/10-contributing/01-development.md b/docs/10-contributing/01-development.md index 86dba9c9..922b714e 100644 --- a/docs/10-contributing/01-development.md +++ b/docs/10-contributing/01-development.md @@ -55,6 +55,7 @@ the command's `run`. The tool only runs the `git` CLI, so | `src/context.rs` | Caller-owned `ExecutionContext`: immutable content-tree cache, validated supplied rebuilds and optional progress observer shared by operations in one invocation. | | `src/repo.rs` | `Repo`: discovery, path selection, reading `.splice`, upstreams, keys and what a splice syncs with. | | `src/rebuild.rs` | The push rebuild: `rebuild_splice`, `splice_start` (B) and the commit loop `rebuild_walk`. | +| `src/tree.rs` | `SpliceTree`: each splice's B, U, R and T at HEAD and an operation's work for it, as a text table for Rust tests. | | `src/state.rs` | `classify_splice`, the sync state every command acts on, and `splice_in`, the re-rooted cherry-pick behind `merge` and `clone`. | | `src/pager.rs` | Paging for `diff` and `log`. | | `src/.rs` | One command each: its `run` and its per-splice step. | diff --git a/src/execution.rs b/src/execution.rs index 141ef111..21c80854 100644 --- a/src/execution.rs +++ b/src/execution.rs @@ -1,6 +1,7 @@ //! Typed observations and in-memory synchronization drivers. use crate::repo::{commit_of, Repo}; +use crate::tree::{SpliceTree, Tasks}; use crate::util::*; use std::process::{Command, Stdio}; @@ -93,6 +94,10 @@ pub(crate) trait Operation { fn finish(&self) -> Res; /// Says what else an --exec hook that stopped the operation left behind. fn stopped(&self) {} + /// The work done and still to do, per splice. + fn tasks(&self) -> Tasks { + Tasks::default() + } } pub(crate) struct Execution<'context, O> { @@ -171,6 +176,12 @@ impl<'context, O: Operation> Execution<'context, O> { Ok(ExecutionOutcome::Complete(status)) } + /// The splice tree at HEAD, with the operation's work per splice. + pub(crate) fn splice_tree(&mut self) -> Res { + let tasks = self.progress.tasks(); + self.repo.splice_tree(tasks) + } + fn checked(&mut self, result: Res) -> Res { if let Err(failure) = &result { self.failed = Some(*failure); diff --git a/src/lib.rs b/src/lib.rs index 180eccad..0c9b5a0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,4 +19,5 @@ pub mod rebuild; pub mod repo; pub mod state; pub mod status; +pub mod tree; pub mod util; diff --git a/src/merge.rs b/src/merge.rs index fc25c556..b61ae463 100644 --- a/src/merge.rs +++ b/src/merge.rs @@ -7,6 +7,7 @@ use crate::fetch::{FetchFailures, FetchPlan, FetchResults}; use crate::message::{self, Message, KNOWN}; use crate::repo::*; use crate::state::*; +use crate::tree::Task; use crate::util::*; use std::collections::HashMap; @@ -870,6 +871,26 @@ impl MergeProgress { matches!(self.phase, MergePhase::Done) } + /// Each splice merged, failed or left in a conflict so far, and each still to merge. + pub(crate) fn tasks(&self) -> Vec<(String, Task)> { + self.paths + .iter() + .enumerate() + .map(|(index, path)| { + let task = if self.skipped.contains(path) { + Task::Conflict + } else if self.failures.contains(path) { + Task::Failed + } else if index < self.next { + Task::Done + } else { + Task::Pending("merge") + }; + (path.clone(), task) + }) + .collect() + } + pub(crate) fn advance(&mut self, repo: &mut Repo) -> Res<()> { if let Some(event) = self.advance_event(repo)? { repo.context.emit(event); diff --git a/src/pull.rs b/src/pull.rs index ab20d83c..7de5b6eb 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -7,6 +7,7 @@ use crate::fetch::{FetchPlan, FetchResults}; use crate::merge::{apply_merge_options, MergeProgress, MERGE_OPTIONS}; use crate::message::{Message, KNOWN}; use crate::repo::*; +use crate::tree::{SpliceTree, Task, Tasks}; use crate::util::*; pub const USAGE: &str = include_str!("usage/pull.txt"); @@ -42,6 +43,12 @@ impl PullExecution<'_> { self.inner.run() } + /// The splice tree at HEAD, with what this pull did and still does for each + /// splice: see [`SpliceTree::render`]. + pub fn splice_tree(&mut self) -> Res { + self.inner.splice_tree() + } + /// Pauses at a selected event. Repository identity, HEAD, branch, fetched /// refs, configuration, tracked files, index and pull markers must remain /// unchanged in the predicate and while paused. @@ -637,6 +644,37 @@ impl Operation for PullProgress { fn stopped(&self) { let _ = self.say_if_detached(); } + fn tasks(&self) -> Tasks { + let pending = |step| { + self.selected + .iter() + .map(|path| (path.clone(), Task::Pending(step))) + .collect() + }; + match &self.phase { + PullPhase::Fetch(_) => Tasks { + per_splice: pending("fetch"), + next: None, + }, + PullPhase::Fetched(_) | PullPhase::Reported => Tasks { + per_splice: pending("merge"), + next: None, + }, + PullPhase::Merge(merges) => Tasks { + per_splice: merges.tasks(), + next: None, + }, + PullPhase::Finalize | PullPhase::Fold { .. } => Tasks { + per_splice: Vec::new(), + next: self.recursive.then_some("fold"), + }, + PullPhase::Cleanup { .. } => Tasks { + per_splice: Vec::new(), + next: Some("clean up"), + }, + PullPhase::Done => Tasks::default(), + } + } } #[cfg(test)] @@ -831,6 +869,193 @@ mod tests { }); } + /// Builds the state of the step-by-step scenario page from its setup.bash: + /// a with b (with c inside) and d, then each upstream moves on and a + /// splices e in. Leaves the current directory in the monorepo. + fn step_by_step_scenario() { + let docs = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs"); + let root = std::env::current_dir().unwrap(); + let mut config = vec![ + ("user.name".to_string(), "Test".to_string()), + ("user.email".to_string(), "test@example.com".to_string()), + ]; + for x in ["a", "b", "c", "d", "e"] { + config.push(( + format!("url.{}/upstream-{x}.git.insteadOf", root.display()), + format!("https://git.example.com/{x}.git"), + )); + } + std::env::set_var("GIT_CONFIG_COUNT", config.len().to_string()); + for (index, (key, value)) in config.iter().enumerate() { + std::env::set_var(format!("GIT_CONFIG_KEY_{index}"), key); + std::env::set_var(format!("GIT_CONFIG_VALUE_{index}"), value); + } + let script = r#" + set -euo pipefail + source "$1" + source "$2" + scenario_recursive_pull "$PWD/monorepo" "$PWD/upstream" + for x in b c d; do + git clone -q "https://git.example.com/$x.git" "$x-work" + echo "$x upstream" >>"$x-work/file.txt" + git -C "$x-work" commit -q -am "${x}2: $x upstream" + git -C "$x-work" push -q origin main + done + git clone -q https://git.example.com/a.git a-work + add_splice a-work https://git.example.com/e.git e main "add e" + echo "a upstream" >>a-work/file.txt + git -C a-work commit -q --amend -am "a2: a upstream, and e spliced in" + git -C a-work push -q origin main + "#; + let status = + std::process::Command::new("bash") + .args(["-c", script, "scenario"]) + .arg(docs.join("tests/helpers/fixtures.bash")) + .arg(docs.join( + "09-scenarios/05-nested-splices/05-recursion/01-recursive-pull/setup.bash", + )) + .status() + .unwrap(); + assert!(status.success()); + std::env::set_current_dir("monorepo").unwrap(); + } + + #[test] + fn recursive_pull_shows_each_splices_commits_and_pending_work_step_by_step() { + with_repository(|| { + step_by_step_scenario(); + let mut named = vec![("s", git(&["rev-parse", "HEAD"]))]; + let mut context = ExecutionContext::new(); + let mut execution = + start(&mut context, &["--recursive".into(), "vendor/a".into()]).unwrap(); + let tree = |execution: &mut PullExecution, named: &[(&str, String)]| { + let tree = execution + .splice_tree() + .unwrap() + .render(crate::tree::labels(named)); + println!("{tree}"); + tree + }; + let mut steps = vec![("start", tree(&mut execution, &named))]; + let wanted = |event: &ExecutionEvent| { + matches!( + event, + ExecutionEvent::AfterFetch { .. } + | ExecutionEvent::AfterMerge { .. } + | ExecutionEvent::BeforeFold { .. } + | ExecutionEvent::AfterFold { .. } + ) + }; + while let ExecutionOutcome::Paused(event) = execution.run_until(wanted).unwrap() { + let step = match event { + ExecutionEvent::AfterMerge { + path, head_after, .. + } => { + let name = match path.rsplit('/').next().unwrap() { + "a" => "pa", + "b" => "pb", + "c" => "pc", + "d" => "pd", + other => panic!("unexpected merge of {other}"), + }; + named.push((name, head_after)); + "after-merge" + } + ExecutionEvent::AfterFetch { .. } => "after-fetch", + ExecutionEvent::BeforeFold { .. } => "before-fold", + ExecutionEvent::AfterFold { head_after, .. } => { + named.push(("p", head_after)); + "after-fold" + } + _ => unreachable!(), + }; + println!("{step}:"); + steps.push((step, tree(&mut execution, &named))); + } + let observed: String = steps + .iter() + .map(|(step, tree)| format!("{step}:\n{tree}")) + .collect::>() + .join("\n"); + assert_eq!( + observed, + r#"start: +monorepo H=s +vendor/a/ B=s U=a1 R=a1 T=a1 pending: fetch + b/ B=s U=b1 R=b1 T=b1 pending: fetch + c/ B=s U=c1 R=c1 T=c1 pending: fetch + d/ B=s U=d1 R=d1 T=d1 pending: fetch + +after-fetch: +monorepo H=s +vendor/a/ B=s U=a1 R=a1 T=a2 pending: merge + b/ B=s U=b1 R=b1 T=b2 pending: merge, after vendor/a/ + c/ B=s U=c1 R=c1 T=c2 pending: merge, after b/ + d/ B=s U=d1 R=d1 T=d2 pending: merge, after vendor/a/ + +after-merge: +monorepo H=pa +vendor/a/ B=pa U=a2 R=a2 T=a2 done + b/ B=pa U=b1 R=b1 T=b2 pending: merge + c/ B=pa U=c1 R=c1 T=c2 pending: merge, after b/ + d/ B=pa U=d1 R=d1 T=d2 pending: merge + e/ B=- U=? R=- T=- + +after-fetch: +monorepo H=pa +vendor/a/ B=pa U=a2 R=a2 T=a2 done + b/ B=pa U=b1 R=b1 T=b2 pending: merge + c/ B=pa U=c1 R=c1 T=c2 pending: merge, after b/ + d/ B=pa U=d1 R=d1 T=d2 pending: merge + e/ B=pa U=e1 R=e1 T=e1 pending: merge + +after-merge: +monorepo H=pb +vendor/a/ B=pa U=a2 R=pb' T=a2 done + b/ B=pb U=b2 R=b2 T=b2 done + c/ B=pb U=c1 R=c1 T=c2 pending: merge + d/ B=pa U=d1 R=d1 T=d2 pending: merge + e/ B=pa U=e1 R=e1 T=e1 done + +after-merge: +monorepo H=pc +vendor/a/ B=pa U=a2 R=pc' T=a2 done + b/ B=pb U=b2 R=pc' T=b2 done + c/ B=pc U=c2 R=c2 T=c2 done + d/ B=pa U=d1 R=d1 T=d2 pending: merge + e/ B=pa U=e1 R=e1 T=e1 done + +after-merge: +monorepo H=pd +vendor/a/ B=pa U=a2 R=pd' T=a2 done + b/ B=pb U=b2 R=pc' T=b2 done + c/ B=pc U=c2 R=c2 T=c2 done + d/ B=pd U=d2 R=d2 T=d2 done + e/ B=pa U=e1 R=e1 T=e1 done + +before-fold: +monorepo H=pd +vendor/a/ B=pa U=a2 R=pd' T=a2 + b/ B=pb U=b2 R=pc' T=b2 + c/ B=pc U=c2 R=c2 T=c2 + d/ B=pd U=d2 R=d2 T=d2 + e/ B=pa U=e1 R=e1 T=e1 +next: fold + +after-fold: +monorepo H=p +vendor/a/ B=s U=a2 R=p' T=a2 + b/ B=s U=b2 R=p' T=b2 + c/ B=p U=c2 R=c2 T=c2 + d/ B=p U=d2 R=d2 T=d2 + e/ B=p U=e1 R=e1 T=e1 +next: clean up +"#, + "observed trees:\n{observed}" + ); + }); + } + #[test] fn pull_rejects_state_changes_between_start_and_first_run() { with_repository(|| { diff --git a/src/tree.rs b/src/tree.rs new file mode 100644 index 00000000..f645d324 --- /dev/null +++ b/src/tree.rs @@ -0,0 +1,228 @@ +//! The splice tree as a table: each splice's boundary B, synced commit U, +//! rebuild R and upstream branch T at HEAD, and the work an operation still +//! has for it. The same picture as a tree drawing, as text. + +use crate::repo::*; +use crate::util::*; + +/// What an operation did or still does for a splice. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Task { + Done, + Pending(&'static str), + Failed, + Conflict, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Node { + pub path: String, + /// B: the monorepo commit the rebuild starts at. + pub boundary: Option, + /// U: the synced commit HEAD's .splice records. + pub synced: String, + /// R: the rebuild push would send. + pub rebuild: Option, + /// T: the fetched upstream branch, or pin, the splice follows. + pub target: Option, + pub task: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpliceTree { + pub head: String, + /// Splices top-down, a parent before the ones inside it. + pub nodes: Vec, + /// The operation's next step that belongs to no single splice. + pub next: Option<&'static str>, +} + +/// An operation's work per splice, and its next step beyond them. +#[derive(Default)] +pub(crate) struct Tasks { + pub per_splice: Vec<(String, Task)>, + pub next: Option<&'static str>, +} + +impl Repo<'_> { + pub(crate) fn splice_tree(&mut self, tasks: Tasks) -> Res { + let head = commit_of("HEAD").ok_or(Fail::Hard)?; + let suffix = format!("/{STATE_FILE}"); + let paths: Vec = self + .state_files("HEAD") + .iter() + .filter_map(|file| file.strip_suffix(&suffix).map(str::to_string)) + .collect(); + let branch = self.monorepo_branch.clone(); + let mut nodes = Vec::new(); + for path in splices_in_order(false, &paths) { + let (boundary, rebuild) = quietly(|| { + ( + self.splice_start(&path, "HEAD").ok().flatten(), + self.rebuild_splice(&path, "HEAD").ok().flatten(), + ) + }); + let target = quietly(|| { + self.require_splice_upstream(&path).ok()?; + let target = self.resolve_upstream_target(&path, &branch).ok()?; + commit_of(&target.reference) + }); + let task = tasks + .per_splice + .iter() + .find(|(done, _)| *done == path) + .map(|(_, task)| task.clone()); + nodes.push(Node { + synced: splice_config(&path, "commit", "HEAD"), + path, + boundary, + rebuild, + target, + task, + }); + } + Ok(SpliceTree { + head, + nodes, + next: tasks.next, + }) + } +} + +impl SpliceTree { + /// The tree as a table, one row per splice, indented inside its parent, + /// with each commit as