Skip to main content

yarli_cli/yarli-core/src/entities/
worktree_binding.rs

1//! Worktree binding entity — explicit worktree state and ownership metadata.
2//!
3//! A `WorktreeBinding` tracks the lifecycle of a Git worktree created for
4//! a run/task. It records the worktree path, branch, base/head refs, dirty
5//! status, and submodule state (Sections 7.3, 12.1).
6
7use std::path::PathBuf;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::yarli_core::domain::{CorrelationId, EventId, RunId, TaskId, WorktreeId};
14use crate::yarli_core::error::TransitionError;
15use crate::yarli_core::fsm::worktree::WorktreeState;
16
17use super::transition::Transition;
18
19/// Submodule policy mode (Section 12.4).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SubmoduleMode {
23    /// Submodule SHAs may not change.
24    Locked,
25    /// Only fast-forward updates to pinned branch allowed.
26    AllowFastForward,
27    /// Unrestricted (requires explicit policy approval).
28    AllowAny,
29}
30
31/// A worktree binding record (Section 12.1).
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct WorktreeBinding {
34    /// Unique worktree binding ID (UUIDv7).
35    pub id: WorktreeId,
36    /// The run this worktree belongs to.
37    pub run_id: RunId,
38    /// The task this worktree is bound to (if any).
39    pub task_id: Option<TaskId>,
40    /// Current FSM state.
41    pub state: WorktreeState,
42    /// Repository root path.
43    pub repo_root: PathBuf,
44    /// Worktree path: `${repo_root}/.yarl/worktrees/{run_id}-{task_id_short}`.
45    pub worktree_path: PathBuf,
46    /// Branch name: `yarl/{run_id}/{task_slug}`.
47    pub branch_name: String,
48    /// Base ref (commit SHA the worktree was created from).
49    pub base_ref: String,
50    /// Head ref (current commit SHA in the worktree).
51    pub head_ref: String,
52    /// Whether the worktree has uncommitted changes.
53    pub dirty: bool,
54    /// Hash of submodule state for change detection.
55    pub submodule_state_hash: Option<String>,
56    /// Submodule policy mode for this worktree.
57    pub submodule_mode: SubmoduleMode,
58    /// Worker that holds the lease on this worktree (if any).
59    pub lease_owner: Option<String>,
60    /// Correlation ID (inherited from parent run).
61    pub correlation_id: CorrelationId,
62    /// When the worktree binding was created.
63    pub created_at: DateTime<Utc>,
64    /// When the worktree last changed state.
65    pub updated_at: DateTime<Utc>,
66}
67
68impl WorktreeBinding {
69    /// Create a new worktree binding in `WtUnbound` state.
70    pub fn new(
71        run_id: RunId,
72        repo_root: impl Into<PathBuf>,
73        branch_name: impl Into<String>,
74        base_ref: impl Into<String>,
75        correlation_id: CorrelationId,
76    ) -> Self {
77        let now = Utc::now();
78        let base = base_ref.into();
79        Self {
80            id: Uuid::now_v7(),
81            run_id,
82            task_id: None,
83            state: WorktreeState::WtUnbound,
84            repo_root: repo_root.into(),
85            worktree_path: PathBuf::new(),
86            branch_name: branch_name.into(),
87            base_ref: base.clone(),
88            head_ref: base,
89            dirty: false,
90            submodule_state_hash: None,
91            submodule_mode: SubmoduleMode::Locked,
92            lease_owner: None,
93            correlation_id,
94            created_at: now,
95            updated_at: now,
96        }
97    }
98
99    /// Bind this worktree to a specific task.
100    pub fn with_task(mut self, task_id: TaskId) -> Self {
101        self.task_id = Some(task_id);
102        self
103    }
104
105    /// Set the submodule policy mode.
106    pub fn with_submodule_mode(mut self, mode: SubmoduleMode) -> Self {
107        self.submodule_mode = mode;
108        self
109    }
110
111    /// Set the worktree path (typically computed during creation).
112    pub fn set_worktree_path(&mut self, path: impl Into<PathBuf>) {
113        self.worktree_path = path.into();
114    }
115
116    /// Update the head ref after a commit or merge.
117    pub fn update_head_ref(&mut self, sha: impl Into<String>) {
118        self.head_ref = sha.into();
119        self.updated_at = Utc::now();
120    }
121
122    /// Mark the worktree as dirty or clean.
123    pub fn set_dirty(&mut self, dirty: bool) {
124        self.dirty = dirty;
125        self.updated_at = Utc::now();
126    }
127
128    /// Update the submodule state hash.
129    pub fn update_submodule_hash(&mut self, hash: impl Into<String>) {
130        self.submodule_state_hash = Some(hash.into());
131        self.updated_at = Utc::now();
132    }
133
134    /// Set the lease owner (worker binding).
135    pub fn set_lease_owner(&mut self, owner: Option<String>) {
136        self.lease_owner = owner;
137        self.updated_at = Utc::now();
138    }
139
140    /// Check if this worktree allows repository-mutating commands.
141    /// Section 12.3: Cannot execute in WtUnbound.
142    pub fn allows_mutations(&self) -> bool {
143        !matches!(
144            self.state,
145            WorktreeState::WtUnbound | WorktreeState::WtClosed | WorktreeState::WtCleanupPending
146        )
147    }
148
149    /// Attempt a state transition. Returns a `Transition` event on success.
150    ///
151    /// Enforces Section 7.3 rules:
152    /// - Terminal states (WtClosed) are immutable.
153    /// - Only valid transitions are allowed.
154    pub fn transition(
155        &mut self,
156        to: WorktreeState,
157        reason: impl Into<String>,
158        actor: impl Into<String>,
159        causation_id: Option<EventId>,
160    ) -> Result<Transition, TransitionError> {
161        let from = self.state;
162
163        if from.is_terminal() {
164            return Err(TransitionError::TerminalState(format!("{from:?}")));
165        }
166
167        if !from.can_transition_to(to) {
168            return Err(TransitionError::InvalidWorktreeTransition { from, to });
169        }
170
171        let reason_str = reason.into();
172        let actor_str = actor.into();
173
174        self.state = to;
175        self.updated_at = Utc::now();
176
177        Ok(Transition::new(
178            "worktree",
179            self.id,
180            format!("{from:?}"),
181            format!("{to:?}"),
182            reason_str,
183            actor_str,
184            self.correlation_id,
185            causation_id,
186        ))
187    }
188}