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