Skip to main content

repo/
repository_resolve.rs

1// SPDX-License-Identifier: Apache-2.0
2//! State resolution helpers for the Repository.
3
4use objects::{
5    object::{Agent, StateId},
6    store::ObjectStore,
7};
8
9use super::{HeddleError, Repository, Result};
10
11impl Repository {
12    /// Resolve a state specifier (HEAD, thread, marker, full/short ID, HEAD~N).
13    pub fn resolve_state(&self, spec: &str) -> Result<Option<StateId>> {
14        if let Some(steps) = parse_head_steps(spec) {
15            return resolve_head_steps(self, steps);
16        }
17
18        if let Some(id) = self.refs.resolve(spec)? {
19            return Ok(Some(id));
20        }
21
22        if self.capability() == super::RepositoryCapability::GitOverlay {
23            if let Some(id) = self.git_overlay_mapped_state_for_branch(spec)? {
24                return Ok(Some(id));
25            }
26            if let Some(id) = self.git_overlay_mapped_state_for_tag(spec)? {
27                return Ok(Some(id));
28            }
29        }
30
31        resolve_short_state_id(self, spec)
32    }
33
34    pub fn resolve_agent(&self) -> Option<Agent> {
35        let provider = std::env::var("HEDDLE_AGENT_PROVIDER")
36            .ok()
37            .or_else(|| self.config.agent.provider.clone());
38        let model = std::env::var("HEDDLE_AGENT_MODEL")
39            .ok()
40            .or_else(|| self.config.agent.model.clone());
41        let session_id = std::env::var("HEDDLE_SESSION_ID").ok();
42        let segment_id = std::env::var("HEDDLE_SESSION_SEGMENT").ok();
43        let policy_id = std::env::var("HEDDLE_AGENT_POLICY")
44            .ok()
45            .or_else(|| self.config.policies.default_policy.clone());
46
47        match (provider, model) {
48            (Some(provider), Some(model)) => {
49                let mut agent = Agent::new(provider, model);
50                if let (Some(sid), Some(segid)) = (session_id, segment_id) {
51                    agent = agent.with_session(sid, segid);
52                }
53                if let Some(policy_id) = policy_id {
54                    agent = agent.with_policy(policy_id);
55                }
56                Some(agent)
57            }
58            _ => None,
59        }
60    }
61}
62
63fn parse_head_steps(spec: &str) -> Option<usize> {
64    if spec == "HEAD" || spec == "@" {
65        return Some(0);
66    }
67
68    let rest = spec
69        .strip_prefix("HEAD~")
70        .or_else(|| spec.strip_prefix("@~"))?;
71    if rest.is_empty() {
72        return None;
73    }
74    rest.parse::<usize>().ok()
75}
76
77fn resolve_head_steps(repo: &Repository, steps: usize) -> Result<Option<StateId>> {
78    let mut current = repo.head()?;
79    if steps == 0 {
80        return Ok(current);
81    }
82
83    for _ in 0..steps {
84        let Some(id) = current else {
85            return Ok(None);
86        };
87        let state = repo.store.get_state(&id)?;
88        let Some(state) = state else {
89            return Ok(None);
90        };
91        current = state.first_parent().copied();
92    }
93
94    Ok(current)
95}
96
97fn resolve_short_state_id(repo: &Repository, spec: &str) -> Result<Option<StateId>> {
98    let prefix = spec.strip_prefix("hs-").unwrap_or(spec).to_lowercase();
99    if prefix.len() < 4 {
100        return Ok(None);
101    }
102
103    let mut matches = Vec::new();
104    for id in repo.store.list_states()? {
105        let full = id.to_string_full();
106        let full_norm = full.strip_prefix("hs-").unwrap_or(&full).to_lowercase();
107        if full_norm.starts_with(&prefix) {
108            matches.push(id);
109        }
110    }
111
112    match matches.len() {
113        0 => Ok(None),
114        1 => Ok(Some(matches[0])),
115        _ => {
116            // Render up to 5 candidates (full form) so callers can
117            // disambiguate without re-listing states.
118            let mut shown: Vec<String> = matches
119                .iter()
120                .take(5)
121                .map(|id| id.to_string_full())
122                .collect();
123            if matches.len() > shown.len() {
124                shown.push(format!("... ({} more)", matches.len() - shown.len()));
125            }
126            Err(HeddleError::Conflict(format!(
127                "ambiguous state ID prefix '{}' matches: {}",
128                spec,
129                shown.join(", ")
130            )))
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use std::fs;
138
139    use objects::{
140        object::{MarkerName, StateId},
141        store::ObjectStore,
142    };
143    use tempfile::TempDir;
144
145    use crate::{HeddleError, Repository};
146
147    /// Init a repo and capture two snapshots so we have a real history
148    /// to resolve against.
149    fn repo_with_two_states() -> (TempDir, Repository, StateId, StateId) {
150        let temp = TempDir::new().unwrap();
151        let repo = Repository::init_default(temp.path()).unwrap();
152        fs::write(temp.path().join("a.txt"), "a").unwrap();
153        let s1 = repo.snapshot(Some("first".into()), None).unwrap();
154        fs::write(temp.path().join("b.txt"), "b").unwrap();
155        let s2 = repo.snapshot(Some("second".into()), None).unwrap();
156        (temp, repo, s1.id(), s2.id())
157    }
158
159    #[test]
160    fn resolve_state_accepts_full_id() {
161        let (_t, repo, s1, _) = repo_with_two_states();
162        let full = s1.to_string_full();
163        let resolved = repo.resolve_state(&full).unwrap();
164        assert_eq!(resolved, Some(s1));
165    }
166
167    #[test]
168    fn resolve_state_accepts_short_prefix() {
169        let (_t, repo, s1, _) = repo_with_two_states();
170        // `short()` is the form `heddle log --json` prints.
171        let short = s1.short();
172        let resolved = repo.resolve_state(&short).unwrap();
173        assert_eq!(resolved, Some(s1));
174    }
175
176    #[test]
177    fn resolve_state_accepts_short_prefix_without_hd_prefix() {
178        // The resolver also tolerates the bare encoding without `hs-`.
179        let (_t, repo, s1, _) = repo_with_two_states();
180        let short = s1.short();
181        let bare = short.strip_prefix("hs-").unwrap();
182        let resolved = repo.resolve_state(bare).unwrap();
183        assert_eq!(resolved, Some(s1));
184    }
185
186    #[test]
187    fn resolve_state_returns_none_for_unknown_id() {
188        let (_t, repo, _, _) = repo_with_two_states();
189        // Length>=4 so we exercise the index path, not the
190        // too-short-prefix shortcut.
191        assert_eq!(repo.resolve_state("hs-zzzz").unwrap(), None);
192    }
193
194    #[test]
195    fn resolve_state_returns_none_for_too_short_prefix() {
196        let (_t, repo, _, _) = repo_with_two_states();
197        assert_eq!(repo.resolve_state("hd").unwrap(), None);
198    }
199
200    #[test]
201    fn resolve_state_accepts_marker_name() {
202        let (_t, repo, s1, _) = repo_with_two_states();
203        repo.refs()
204            .create_marker(&MarkerName::new("milestone-1"), &s1)
205            .unwrap();
206        let resolved = repo.resolve_state("milestone-1").unwrap();
207        assert_eq!(resolved, Some(s1));
208    }
209
210    #[test]
211    fn resolve_state_accepts_head() {
212        let (_t, repo, _, s2) = repo_with_two_states();
213        let resolved = repo.resolve_state("HEAD").unwrap();
214        assert_eq!(resolved, Some(s2));
215    }
216
217    #[test]
218    fn resolve_state_accepts_head_steps() {
219        let (_t, repo, s1, s2) = repo_with_two_states();
220        assert_eq!(repo.resolve_state("HEAD").unwrap(), Some(s2));
221        assert_eq!(repo.resolve_state("HEAD~1").unwrap(), Some(s1));
222    }
223
224    /// Ambiguous-prefix detection for content-addressed StateIds.
225    #[test]
226    fn resolve_state_errors_on_ambiguous_prefix() {
227        use objects::object::{Attribution, State};
228        let temp = TempDir::new().unwrap();
229        let repo = Repository::init_default(temp.path()).unwrap();
230
231        let head = repo.head().unwrap().unwrap();
232        let head_state = repo.store().get_state(&head).unwrap().unwrap();
233        let mut by_prefix = std::collections::HashMap::new();
234        let (prefix, state_a, state_b) = (0..10_000)
235            .find_map(|index| {
236                let state = State::new(
237                    head_state.tree,
238                    vec![head],
239                    Attribution::human(objects::object::Principal::new(
240                        format!("Collision Candidate {index}"),
241                        "collision@example.test",
242                    )),
243                );
244                let id = state.id();
245                let full = id.to_string_full();
246                let prefix = full.strip_prefix("hs-").unwrap()[..4].to_string();
247                by_prefix
248                    .insert(prefix.clone(), state.clone())
249                    .map(|previous| (format!("hs-{prefix}"), previous, state))
250            })
251            .expect("10,000 content-addressed states should contain a four-character collision");
252        let id_a = state_a.id();
253        let id_b = state_b.id();
254        repo.store().put_state(&state_a).unwrap();
255        repo.store().put_state(&state_b).unwrap();
256
257        // Sanity: both states must be visible to `list_states` for
258        // the resolver to consider them.
259        let listed = repo.store().list_states().unwrap();
260        assert!(
261            listed.contains(&id_a),
262            "state A must be indexed: {listed:?}"
263        );
264        assert!(listed.contains(&id_b), "state B must be indexed");
265
266        let err = repo.resolve_state(&prefix).unwrap_err();
267        let msg = err.to_string();
268        assert!(
269            msg.contains("ambiguous state ID prefix"),
270            "unexpected error: {msg}"
271        );
272        assert!(msg.contains(&prefix), "error should echo the prefix: {msg}");
273        assert!(matches!(err, HeddleError::Conflict(_)));
274    }
275}