Skip to main content

repo/
thread_stack.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Thread stacks — first-class read API for the descendant tree of a
3//! thread, formed by walking [`ThreadRecord::parent_thread`] links.
4//!
5//! A "stack" is the descendant tree of a single root thread. Roots are
6//! threads whose `parent_thread` either is `None` or points at a name
7//! not present in the supplied record list (e.g. `main` / a deleted
8//! parent). That keeps stack discovery local and resilient to detached
9//! refs — we never pretend a thread has a parent we can't see.
10//!
11//! The discovery side ([`compute_stacks`], [`stack_for`]) is read-only.
12//! The planner ([`plan_stack_rebase`]) returns an ordered, BFS plan that
13//! the existing single-thread rebase machinery executes one step at a
14//! time; this module never mutates state.
15//!
16//! Conceptual ancestor: HeddleCo/weft#46. This is an adaptation, not a
17//! cherry-pick — the original was written against the pre-#78 weft
18//! crate layout. See `tests/stack_rebase.rs` (in `crates/cli`) for the
19//! integration coverage.
20
21use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
22
23use objects::object::ThreadName;
24
25use crate::{Repository, Result, thread_model::ThreadRecord, thread_storage::ThreadManager};
26
27/// One node in a stack tree. `name` matches [`ThreadRecord::thread`];
28/// `children` are the immediate descendants in the same stack, sorted
29/// by name for stable rendering.
30#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub struct StackNode {
32    pub name: String,
33    pub children: Vec<StackNode>,
34}
35
36impl StackNode {
37    /// Total number of threads in this subtree, including the node itself.
38    pub fn size(&self) -> usize {
39        1 + self.children.iter().map(StackNode::size).sum::<usize>()
40    }
41
42    /// Depth of the deepest leaf below this node. A root with no children
43    /// has depth 0.
44    pub fn depth(&self) -> usize {
45        self.children
46            .iter()
47            .map(|c| 1 + c.depth())
48            .max()
49            .unwrap_or(0)
50    }
51
52    /// Yield every thread name in the subtree, root first, depth-first.
53    pub fn iter_names(&self) -> impl Iterator<Item = &str> {
54        let mut stack: Vec<&StackNode> = vec![self];
55        std::iter::from_fn(move || {
56            let next = stack.pop()?;
57            // Push children in reverse so the iterator yields them in the
58            // original sorted order.
59            for child in next.children.iter().rev() {
60                stack.push(child);
61            }
62            Some(next.name.as_str())
63        })
64    }
65}
66
67/// One discovered stack — the root thread plus its full descendant tree.
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69pub struct ThreadStack {
70    pub root: StackNode,
71}
72
73impl ThreadStack {
74    pub fn member_count(&self) -> usize {
75        self.root.size()
76    }
77
78    pub fn depth(&self) -> usize {
79        self.root.depth()
80    }
81
82    pub fn root_name(&self) -> &str {
83        &self.root.name
84    }
85
86    /// Names of every thread in the stack, root first.
87    pub fn member_names(&self) -> Vec<&str> {
88        self.root.iter_names().collect()
89    }
90
91    /// Whether `thread_name` is part of this stack.
92    pub fn contains(&self, thread_name: &str) -> bool {
93        self.root.iter_names().any(|name| name == thread_name)
94    }
95}
96
97/// Compute every stack in `records`. Returned stacks are sorted by root
98/// name for deterministic output.
99///
100/// A stack is rooted at a record whose `parent_thread` is `None` or
101/// names a thread not in the list. Cycles are skipped silently.
102pub fn compute_stacks(records: &[ThreadRecord]) -> Vec<ThreadStack> {
103    let by_name: BTreeMap<&str, &ThreadRecord> =
104        records.iter().map(|r| (r.thread.as_str(), r)).collect();
105    let children_of = children_index(records, &by_name);
106
107    let roots: Vec<&str> = by_name
108        .keys()
109        .filter(|name| {
110            let record = by_name[*name];
111            match record.parent_thread.as_deref() {
112                None => true,
113                Some(parent) => !by_name.contains_key(parent),
114            }
115        })
116        .copied()
117        .collect();
118
119    let mut stacks = Vec::with_capacity(roots.len());
120    for root in roots {
121        let mut visited = HashSet::new();
122        if let Some(node) = build_node(root, &children_of, &mut visited) {
123            stacks.push(ThreadStack { root: node });
124        }
125    }
126    stacks
127}
128
129/// Find the stack containing `thread_name`. Returns `None` if the thread
130/// doesn't exist in `records`.
131///
132/// Walks parents up to the stack root before computing the descendant
133/// tree, so callers always get the full picture.
134pub fn stack_for(records: &[ThreadRecord], thread_name: &str) -> Option<ThreadStack> {
135    let by_name: BTreeMap<&str, &ThreadRecord> =
136        records.iter().map(|r| (r.thread.as_str(), r)).collect();
137    by_name.get(thread_name)?;
138
139    let mut cursor: &str = thread_name;
140    let mut seen: HashSet<&str> = HashSet::new();
141    loop {
142        if !seen.insert(cursor) {
143            // Cycle — bail.
144            return None;
145        }
146        let record = match by_name.get(cursor) {
147            Some(r) => *r,
148            None => break,
149        };
150        match record.parent_thread.as_deref() {
151            Some(name) if by_name.contains_key(name) => {
152                cursor = name;
153            }
154            _ => break,
155        }
156    }
157    let root_name = cursor;
158
159    let children_of = children_index(records, &by_name);
160    let mut visited = HashSet::new();
161    let node = build_node(root_name, &children_of, &mut visited)?;
162    Some(ThreadStack { root: node })
163}
164
165fn children_index<'a>(
166    records: &'a [ThreadRecord],
167    by_name: &BTreeMap<&'a str, &'a ThreadRecord>,
168) -> HashMap<&'a str, Vec<&'a str>> {
169    let mut children_of: HashMap<&str, Vec<&str>> = HashMap::new();
170    for record in records {
171        if let Some(parent) = record.parent_thread.as_deref()
172            && by_name.contains_key(parent)
173        {
174            children_of
175                .entry(parent)
176                .or_default()
177                .push(record.thread.as_str());
178        }
179    }
180    for kids in children_of.values_mut() {
181        kids.sort();
182    }
183    children_of
184}
185
186fn build_node<'a>(
187    name: &'a str,
188    children_of: &HashMap<&'a str, Vec<&'a str>>,
189    visited: &mut HashSet<&'a str>,
190) -> Option<StackNode> {
191    if !visited.insert(name) {
192        // Cycle protection — refuse to render the second occurrence.
193        return None;
194    }
195    let children = children_of
196        .get(name)
197        .map(|kids| {
198            kids.iter()
199                .filter_map(|child| build_node(child, children_of, visited))
200                .collect()
201        })
202        .unwrap_or_default();
203    Some(StackNode {
204        name: name.to_string(),
205        children,
206    })
207}
208
209// ── Rebase planning ──────────────────────────────────────────────────────
210
211/// One thread's worth of work in a stack-rebase plan.
212#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
213pub struct StackRebaseStep {
214    /// Thread ref name being moved.
215    pub thread: String,
216    /// State the thread points at today.
217    pub current_state: String,
218    /// State the thread is currently rebased on. Lets the executor compute
219    /// the delta `(old_base..current_state]` to replay onto `new_base`.
220    pub old_base: String,
221    /// State the thread should be replayed onto. For the root this is
222    /// the caller-supplied `onto`; for descendants it's the parent's
223    /// projected new tip.
224    pub new_base: String,
225    /// Parent thread name, if any. Roots have `None`.
226    pub parent_thread: Option<String>,
227    /// Distance from the stack root. Root is 0.
228    pub depth: usize,
229}
230
231/// Ordered, root-first plan for rebasing an entire stack.
232#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
233pub struct StackRebasePlan {
234    pub root_thread: String,
235    pub onto: String,
236    pub steps: Vec<StackRebaseStep>,
237}
238
239impl StackRebasePlan {
240    pub fn step_count(&self) -> usize {
241        self.steps.len()
242    }
243
244    /// `true` if every step's current state already matches its target —
245    /// no thread needs to move. Lets callers print "already up to date".
246    pub fn is_no_op(&self) -> bool {
247        self.steps
248            .iter()
249            .all(|step| step.current_state == step.new_base)
250    }
251}
252
253#[derive(Debug, thiserror::Error, PartialEq, Eq)]
254pub enum PlanRebaseError {
255    #[error("thread '{0}' not found")]
256    ThreadNotFound(String),
257    #[error("thread '{0}' is not a stack root (has parent '{1}'); rebase the root instead")]
258    NotARoot(String, String),
259}
260
261/// Compute a rebase plan for the stack rooted at `root_thread`.
262///
263/// BFS-orders descendants so each child is planned after its parent has
264/// already been assigned a new base. For descendants whose current state
265/// equals their old base (no commits past the branch point), `new_base`
266/// is inherited unchanged — those threads are no-ops and the executor
267/// can fast-forward them.
268///
269/// `current_state_for(thread_name)` returns the thread's current tip,
270/// passed in as a closure so the planner stays a pure function.
271pub fn plan_stack_rebase<F>(
272    records: &[ThreadRecord],
273    root_thread: &str,
274    onto: &str,
275    mut current_state_for: F,
276) -> std::result::Result<StackRebasePlan, PlanRebaseError>
277where
278    F: FnMut(&str) -> String,
279{
280    let by_name: BTreeMap<&str, &ThreadRecord> =
281        records.iter().map(|r| (r.thread.as_str(), r)).collect();
282    let root_record = by_name
283        .get(root_thread)
284        .ok_or_else(|| PlanRebaseError::ThreadNotFound(root_thread.to_string()))?;
285
286    if let Some(parent) = root_record.parent_thread.as_deref()
287        && by_name.contains_key(parent)
288    {
289        return Err(PlanRebaseError::NotARoot(
290            root_thread.to_string(),
291            parent.to_string(),
292        ));
293    }
294
295    let children_of = children_index(records, &by_name);
296
297    // BFS so children are visited only after their parents.
298    let mut steps = Vec::new();
299    let mut queue: VecDeque<(&str, usize, String)> = VecDeque::new();
300    queue.push_back((root_thread, 0, onto.to_string()));
301
302    while let Some((thread_name, depth, new_base)) = queue.pop_front() {
303        let record = by_name[thread_name];
304        let current = current_state_for(thread_name);
305        // The thread's projected new tip = `new_base` if there are no
306        // commits past the old base (no-op), otherwise a synthetic
307        // "${thread}@projected" handle that descendants reference. The
308        // executor replaces these with the real new change_id when each
309        // rebase lands.
310        let projected_tip = if current == record.base_state {
311            new_base.clone()
312        } else {
313            format!("{thread_name}@projected")
314        };
315
316        // A step is a root iff `parent_thread.is_none()`. The source
317        // record's `parent_thread` may point at a thread outside the
318        // record set (e.g. `main`); for the planner that still counts
319        // as a root and the field is informational/historical only, so
320        // we erase it at depth 0 to make root-detection unambiguous
321        // downstream.
322        let parent_thread = if depth == 0 {
323            None
324        } else {
325            record.parent_thread.clone()
326        };
327
328        steps.push(StackRebaseStep {
329            thread: thread_name.to_string(),
330            current_state: current,
331            old_base: record.base_state.clone(),
332            new_base: new_base.clone(),
333            parent_thread,
334            depth,
335        });
336
337        if let Some(kids) = children_of.get(thread_name) {
338            for child in kids {
339                queue.push_back((child, depth + 1, projected_tip.clone()));
340            }
341        }
342    }
343
344    Ok(StackRebasePlan {
345        root_thread: root_thread.to_string(),
346        onto: onto.to_string(),
347        steps,
348    })
349}
350
351// ── Repository extension methods ────────────────────────────────────────
352
353impl Repository {
354    /// Load every thread record from disk and compute every stack in the
355    /// repo. Convenience wrapper around [`compute_stacks`] that handles
356    /// the [`ThreadManager`] read.
357    pub fn compute_thread_stacks(&self) -> Result<Vec<ThreadStack>> {
358        let records = ThreadManager::new(self.heddle_dir()).list_records()?;
359        Ok(compute_stacks(&records))
360    }
361
362    /// Find the stack containing `thread_name`, walking up to the root
363    /// before computing the descendant tree. Returns `None` when the
364    /// thread isn't in the corpus.
365    pub fn thread_stack_for(&self, thread_name: &str) -> Result<Option<ThreadStack>> {
366        let records = ThreadManager::new(self.heddle_dir()).list_records()?;
367        Ok(stack_for(&records, thread_name))
368    }
369
370    /// Plan a rebase for the stack rooted at `root_thread`. The planner
371    /// reads each thread's live tip via `refs.get_thread`, so it stays
372    /// correct even when the on-disk `current_state` in
373    /// [`ThreadRecord`] hasn't been refreshed yet.
374    ///
375    /// Returns `Ok(Err(PlanRebaseError))` for shape-level errors
376    /// (unknown root, non-root target) so callers can distinguish a
377    /// planner refusal from an I/O failure.
378    pub fn plan_thread_stack_rebase(
379        &self,
380        root_thread: &str,
381        onto: &str,
382    ) -> Result<std::result::Result<StackRebasePlan, PlanRebaseError>> {
383        let records = ThreadManager::new(self.heddle_dir()).list_records()?;
384        // Pre-fetch live tips so ref I/O errors surface here instead of
385        // being swallowed inside the (infallible) planner closure. A
386        // truly-absent thread ref (Ok(None)) is allowed — the planner
387        // falls back to the thread name as a sentinel — but an Err from
388        // `get_thread` must propagate, otherwise downstream code would
389        // act on a fake base/tip and could produce destructive plans.
390        let refs = self.refs();
391        let mut tips: HashMap<String, String> = HashMap::new();
392        for record in &records {
393            if let Some(id) = refs.get_thread(&ThreadName::from(record.thread.as_str()))? {
394                tips.insert(record.thread.clone(), id.to_string());
395            }
396        }
397        let plan = plan_stack_rebase(&records, root_thread, onto, |name| {
398            tips.get(name).cloned().unwrap_or_else(|| name.to_string())
399        });
400        Ok(plan)
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use chrono::Utc;
407
408    use super::*;
409    use crate::thread_model::{ThreadFreshness, ThreadMode, ThreadState};
410
411    fn record(name: &str, parent: Option<&str>) -> ThreadRecord {
412        ThreadRecord {
413            id: format!("id-{name}"),
414            thread: name.to_string(),
415            target_thread: None,
416            parent_thread: parent.map(str::to_string),
417            mode: ThreadMode::Materialized,
418            state: ThreadState::Active,
419            base_state: "base".to_string(),
420            base_root: "root".to_string(),
421            current_state: None,
422            merged_state: None,
423            task: None,
424            changed_paths: Vec::new(),
425            impact_categories: Vec::new(),
426            heavy_impact_paths: Vec::new(),
427            promotion_suggested: false,
428            freshness: ThreadFreshness::Unknown,
429            verification_summary: Default::default(),
430            confidence_summary: Default::default(),
431            integration_policy_result: Default::default(),
432            created_at: Utc::now(),
433            updated_at: Utc::now(),
434            ephemeral: None,
435            auto: false,
436            shared_target_dir: None,
437        }
438    }
439
440    fn record_at(name: &str, parent: Option<&str>, base: &str) -> ThreadRecord {
441        let mut r = record(name, parent);
442        r.base_state = base.to_string();
443        r
444    }
445
446    fn current_states<'a>(map: &'a HashMap<&'a str, &'a str>) -> impl FnMut(&str) -> String + 'a {
447        move |name: &str| map.get(name).copied().unwrap_or(name).to_string()
448    }
449
450    #[test]
451    fn empty_input_yields_no_stacks() {
452        assert!(compute_stacks(&[]).is_empty());
453    }
454
455    #[test]
456    fn single_orphan_thread_is_its_own_stack() {
457        let records = vec![record("feature-a", None)];
458        let stacks = compute_stacks(&records);
459        assert_eq!(stacks.len(), 1);
460        assert_eq!(stacks[0].root_name(), "feature-a");
461        assert_eq!(stacks[0].member_count(), 1);
462        assert_eq!(stacks[0].depth(), 0);
463    }
464
465    #[test]
466    fn linear_three_deep_stack() {
467        let records = vec![
468            record("feature-a", None),
469            record("feature-b", Some("feature-a")),
470            record("feature-c", Some("feature-b")),
471        ];
472        let stacks = compute_stacks(&records);
473        assert_eq!(stacks.len(), 1);
474        let stack = &stacks[0];
475        assert_eq!(stack.root_name(), "feature-a");
476        assert_eq!(stack.member_count(), 3);
477        assert_eq!(stack.depth(), 2);
478        assert_eq!(
479            stack.member_names(),
480            vec!["feature-a", "feature-b", "feature-c"]
481        );
482    }
483
484    #[test]
485    fn parent_outside_list_promotes_child_to_root() {
486        let records = vec![
487            record("feature-a", Some("main")),
488            record("feature-b", Some("feature-a")),
489        ];
490        let stacks = compute_stacks(&records);
491        assert_eq!(stacks.len(), 1);
492        assert_eq!(stacks[0].root_name(), "feature-a");
493        assert_eq!(stacks[0].member_count(), 2);
494    }
495
496    #[test]
497    fn cycle_does_not_panic_or_loop_forever() {
498        let records = vec![record("a", Some("b")), record("b", Some("a"))];
499        let stacks = compute_stacks(&records);
500        assert!(stacks.is_empty());
501    }
502
503    #[test]
504    fn stack_for_walks_up_to_root_then_returns_full_tree() {
505        let records = vec![
506            record("feature-a", None),
507            record("feature-b", Some("feature-a")),
508            record("feature-c", Some("feature-b")),
509            record("feature-d", Some("feature-a")),
510        ];
511        let from_root = stack_for(&records, "feature-a").unwrap();
512        let from_leaf = stack_for(&records, "feature-c").unwrap();
513        let from_sibling = stack_for(&records, "feature-d").unwrap();
514        assert_eq!(from_root, from_leaf);
515        assert_eq!(from_root, from_sibling);
516        assert_eq!(from_root.member_count(), 4);
517    }
518
519    #[test]
520    fn stack_for_returns_none_for_unknown_thread() {
521        assert!(stack_for(&[record("feature-a", None)], "missing").is_none());
522    }
523
524    #[test]
525    fn plan_rejects_unknown_root() {
526        let records: Vec<ThreadRecord> = Vec::new();
527        let err =
528            plan_stack_rebase(&records, "missing", "new-base", |_| String::new()).unwrap_err();
529        assert_eq!(err, PlanRebaseError::ThreadNotFound("missing".into()));
530    }
531
532    #[test]
533    fn plan_rejects_non_root_when_parent_present() {
534        let records = vec![
535            record_at("feature-a", None, "main-1"),
536            record_at("feature-b", Some("feature-a"), "feature-a-tip"),
537        ];
538        let err =
539            plan_stack_rebase(&records, "feature-b", "main-2", |n| n.to_string()).unwrap_err();
540        assert_eq!(
541            err,
542            PlanRebaseError::NotARoot("feature-b".into(), "feature-a".into())
543        );
544    }
545
546    #[test]
547    fn plan_for_single_orphan_yields_one_step() {
548        let records = vec![record_at("feature-a", None, "main-1")];
549        let mut current = HashMap::new();
550        current.insert("feature-a", "feature-a-tip");
551        let plan =
552            plan_stack_rebase(&records, "feature-a", "main-2", current_states(&current)).unwrap();
553        assert_eq!(plan.step_count(), 1);
554        let step = &plan.steps[0];
555        assert_eq!(step.thread, "feature-a");
556        assert_eq!(step.current_state, "feature-a-tip");
557        assert_eq!(step.old_base, "main-1");
558        assert_eq!(step.new_base, "main-2");
559        assert_eq!(step.depth, 0);
560        assert_eq!(step.parent_thread, None);
561        assert!(!plan.is_no_op());
562    }
563
564    #[test]
565    fn plan_orders_descendants_after_parents_bfs() {
566        // a → { b → c, d }
567        let records = vec![
568            record_at("a", None, "main-1"),
569            record_at("b", Some("a"), "a-tip"),
570            record_at("c", Some("b"), "b-tip"),
571            record_at("d", Some("a"), "a-tip"),
572        ];
573        let mut current = HashMap::new();
574        current.insert("a", "a-tip");
575        current.insert("b", "b-tip");
576        current.insert("c", "c-tip");
577        current.insert("d", "d-tip");
578        let plan = plan_stack_rebase(&records, "a", "main-2", current_states(&current)).unwrap();
579        let order: Vec<&str> = plan.steps.iter().map(|s| s.thread.as_str()).collect();
580        assert_eq!(order, vec!["a", "b", "d", "c"]);
581
582        let by_thread: HashMap<&str, &StackRebaseStep> =
583            plan.steps.iter().map(|s| (s.thread.as_str(), s)).collect();
584        assert_eq!(by_thread["a"].new_base, "main-2");
585        assert_eq!(by_thread["b"].new_base, "a@projected");
586        assert_eq!(by_thread["d"].new_base, "a@projected");
587        assert_eq!(by_thread["c"].new_base, "b@projected");
588    }
589
590    #[test]
591    fn plan_root_step_parent_thread_is_none_even_when_record_names_external_parent() {
592        // The record names `main` as parent. Since `main` isn't in the
593        // record set, `feat-a` is treated as a root. The emitted step's
594        // `parent_thread` must be `None` regardless of what the source
595        // record says — downstream root-classification keys off that
596        // field, not on `depth == 0`.
597        let records = vec![
598            record_at("feat-a", Some("main"), "main-1"),
599            record_at("feat-b", Some("feat-a"), "feat-a-tip"),
600        ];
601        let mut current = HashMap::new();
602        current.insert("feat-a", "feat-a-tip");
603        current.insert("feat-b", "feat-b-tip");
604        let plan =
605            plan_stack_rebase(&records, "feat-a", "main-2", current_states(&current)).unwrap();
606        let by_thread: HashMap<&str, &StackRebaseStep> =
607            plan.steps.iter().map(|s| (s.thread.as_str(), s)).collect();
608        assert_eq!(
609            by_thread["feat-a"].parent_thread, None,
610            "root step must erase the record's external parent"
611        );
612        assert_eq!(
613            by_thread["feat-b"].parent_thread,
614            Some("feat-a".to_string()),
615            "descendant step must retain its in-stack parent"
616        );
617    }
618
619    #[test]
620    fn plan_reports_no_op_when_everything_already_aligned() {
621        let records = vec![record_at("a", None, "main-2")];
622        let mut current = HashMap::new();
623        current.insert("a", "main-2");
624        let plan = plan_stack_rebase(&records, "a", "main-2", current_states(&current)).unwrap();
625        assert!(plan.is_no_op());
626    }
627}