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, ChangeId},
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<ChangeId>> {
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_change_for_branch(spec)? {
24                return Ok(Some(id));
25            }
26            if let Some(id) = self.git_overlay_mapped_change_for_tag(spec)? {
27                return Ok(Some(id));
28            }
29        }
30
31        resolve_short_change_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<ChangeId>> {
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_change_id(repo: &Repository, spec: &str) -> Result<Option<ChangeId>> {
98    let prefix = spec.strip_prefix("hd-").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("hd-").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::{ChangeId, MarkerName},
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, ChangeId, ChangeId) {
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.change_id, s2.change_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 `hd-`.
179        let (_t, repo, s1, _) = repo_with_two_states();
180        let short = s1.short();
181        let bare = short.strip_prefix("hd-").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("hd-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: synthesize two states whose full
225    /// IDs share a common prefix by writing them straight to the store
226    /// at hand-picked IDs. Going through the snapshot path can't
227    /// reliably produce a collision because change IDs are random.
228    #[test]
229    fn resolve_state_errors_on_ambiguous_prefix() {
230        use objects::object::{Attribution, State};
231        let temp = TempDir::new().unwrap();
232        let repo = Repository::init_default(temp.path()).unwrap();
233
234        // Build two distinct ChangeIds that share an encoded prefix.
235        // Crockford base32 encodes 5 bits per char, so identical
236        // first 4 bytes (32 bits) guarantee the first 7 chars of the
237        // encoded form match.
238        let mut id_a_bytes = [0u8; 16];
239        let mut id_b_bytes = [0u8; 16];
240        id_a_bytes[..4].copy_from_slice(&[0xaa, 0xaa, 0xaa, 0xaa]);
241        id_b_bytes[..4].copy_from_slice(&[0xaa, 0xaa, 0xaa, 0xaa]);
242        id_a_bytes[15] = 0x01;
243        id_b_bytes[15] = 0x02;
244
245        let id_a = ChangeId::from_bytes(id_a_bytes);
246        let id_b = ChangeId::from_bytes(id_b_bytes);
247        assert_ne!(id_a, id_b);
248
249        // Persist States with hand-picked change_ids by going through
250        // the store's `put_state` (which writes by `state.change_id`).
251        let head = repo.head().unwrap().unwrap();
252        let head_state = repo.store().get_state(&head).unwrap().unwrap();
253        let principal = repo.get_principal().unwrap();
254        let state_a = State::new(
255            head_state.tree,
256            vec![head],
257            Attribution::human(principal.clone()),
258        )
259        .with_change_id(id_a);
260        let state_b = State::new(head_state.tree, vec![head], Attribution::human(principal))
261            .with_change_id(id_b);
262        repo.store().put_state(&state_a).unwrap();
263        repo.store().put_state(&state_b).unwrap();
264
265        // Sanity: both states must be visible to `list_states` for
266        // the resolver to consider them.
267        let listed = repo.store().list_states().unwrap();
268        assert!(
269            listed.contains(&id_a),
270            "state A must be indexed: {listed:?}"
271        );
272        assert!(listed.contains(&id_b), "state B must be indexed");
273
274        // 7-char encoded prefix ("hd-" + 4 base32 chars from
275        // identical first bytes) — strictly less than the 12-char
276        // "short" form so we know we're not hitting an exact match.
277        let full_a = id_a.to_string_full();
278        let prefix = &full_a[..7];
279        assert!(prefix.starts_with("hd-"));
280
281        let err = repo.resolve_state(prefix).unwrap_err();
282        let msg = err.to_string();
283        assert!(
284            msg.contains("ambiguous state ID prefix"),
285            "unexpected error: {msg}"
286        );
287        assert!(msg.contains(prefix), "error should echo the prefix: {msg}");
288        assert!(matches!(err, HeddleError::Conflict(_)));
289    }
290}