Skip to main content

opensession_git_native/
handoff_artifact_store.rs

1use std::path::Path;
2use std::process::Command;
3
4use gix::object::tree::EntryKind;
5use gix::ObjectId;
6
7use crate::error::{GitStorageError, Result};
8use crate::ops::{self, gix_err};
9use crate::HANDOFF_ARTIFACTS_REF_PREFIX;
10
11const ARTIFACT_BLOB_PATH: &str = "artifact.json";
12
13pub fn artifact_ref_name(artifact_id: &str) -> String {
14    format!("{HANDOFF_ARTIFACTS_REF_PREFIX}/{}", artifact_id.trim())
15}
16
17pub fn store_handoff_artifact(
18    repo_path: &Path,
19    artifact_id: &str,
20    artifact_json: &[u8],
21) -> Result<String> {
22    let repo = ops::open_repo(repo_path)?;
23    let hash_kind = repo.object_hash();
24    let ref_name = artifact_ref_name(artifact_id);
25
26    let artifact_blob = repo.write_blob(artifact_json).map_err(gix_err)?.detach();
27
28    let tip = ops::find_ref_tip(&repo, &ref_name)?;
29    let base_tree_id = match &tip {
30        Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
31        None => ObjectId::empty_tree(hash_kind),
32    };
33
34    let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
35    editor
36        .upsert(ARTIFACT_BLOB_PATH, EntryKind::Blob, artifact_blob)
37        .map_err(gix_err)?;
38    let new_tree_id = editor.write().map_err(gix_err)?.detach();
39
40    let parent = tip.map(|id| id.detach());
41    let message = format!("handoff-artifact: {artifact_id}");
42    let _ = ops::create_commit(&repo, &ref_name, new_tree_id, parent, &message)?;
43    Ok(ref_name)
44}
45
46pub fn load_handoff_artifact(repo_path: &Path, id_or_ref: &str) -> Result<Vec<u8>> {
47    let _ = ops::open_repo(repo_path)?;
48    let ref_name = normalize_ref_name(id_or_ref);
49
50    let output = git_cmd(
51        repo_path,
52        &["show", &format!("{ref_name}:{ARTIFACT_BLOB_PATH}")],
53    )?;
54    if !output.status.success() {
55        return Err(GitStorageError::NotFound(ref_name));
56    }
57    Ok(output.stdout)
58}
59
60pub fn list_handoff_artifact_refs(repo_path: &Path) -> Result<Vec<String>> {
61    let _ = ops::open_repo(repo_path)?;
62    let output = git_cmd(
63        repo_path,
64        &[
65            "for-each-ref",
66            "--format=%(refname)",
67            HANDOFF_ARTIFACTS_REF_PREFIX,
68        ],
69    )?;
70    if !output.status.success() {
71        return Err(GitStorageError::Other(
72            String::from_utf8_lossy(&output.stderr).trim().to_string(),
73        ));
74    }
75    let mut refs = String::from_utf8_lossy(&output.stdout)
76        .lines()
77        .map(str::trim)
78        .filter(|line| !line.is_empty())
79        .map(ToOwned::to_owned)
80        .collect::<Vec<_>>();
81    refs.sort();
82    Ok(refs)
83}
84
85fn normalize_ref_name(value: &str) -> String {
86    if value.starts_with("refs/") {
87        value.to_string()
88    } else {
89        artifact_ref_name(value)
90    }
91}
92
93fn git_cmd(repo_path: &Path, args: &[&str]) -> Result<std::process::Output> {
94    Command::new("git")
95        .args(args)
96        .current_dir(repo_path)
97        .env_remove("GIT_DIR")
98        .env_remove("GIT_WORK_TREE")
99        .env_remove("GIT_COMMON_DIR")
100        .env_remove("GIT_INDEX_FILE")
101        .env_remove("GIT_OBJECT_DIRECTORY")
102        .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
103        .output()
104        .map_err(GitStorageError::Io)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::ops;
111    use crate::test_utils::init_test_repo;
112
113    #[test]
114    fn store_list_and_load_handoff_artifact() {
115        let tmp = tempfile::tempdir().expect("tempdir");
116        init_test_repo(tmp.path());
117
118        let ref_name = store_handoff_artifact(tmp.path(), "artifact-1", br#"{"ok":true}"#)
119            .expect("store artifact");
120        assert_eq!(
121            ref_name,
122            "refs/opensession/handoff/artifacts/artifact-1".to_string()
123        );
124
125        let refs = list_handoff_artifact_refs(tmp.path()).expect("list refs");
126        assert_eq!(refs, vec![ref_name.clone()]);
127
128        let bytes = load_handoff_artifact(tmp.path(), "artifact-1").expect("load artifact");
129        assert_eq!(String::from_utf8_lossy(&bytes), "{\"ok\":true}");
130    }
131
132    #[test]
133    fn storing_existing_artifact_updates_tip() {
134        let tmp = tempfile::tempdir().expect("tempdir");
135        init_test_repo(tmp.path());
136
137        let ref_name =
138            store_handoff_artifact(tmp.path(), "artifact-2", br#"{"rev":1}"#).expect("store");
139        let repo = gix::open(tmp.path()).expect("open");
140        let first_tip = ops::find_ref_tip(&repo, &ref_name)
141            .expect("tip")
142            .expect("ref exists")
143            .detach();
144
145        store_handoff_artifact(tmp.path(), "artifact-2", br#"{"rev":2}"#).expect("store update");
146        let repo = gix::open(tmp.path()).expect("open");
147        let second_tip = ops::find_ref_tip(&repo, &ref_name)
148            .expect("tip")
149            .expect("ref exists")
150            .detach();
151
152        assert_ne!(first_tip, second_tip);
153        let bytes = load_handoff_artifact(tmp.path(), "artifact-2").expect("load");
154        assert_eq!(String::from_utf8_lossy(&bytes), "{\"rev\":2}");
155    }
156
157    #[test]
158    fn load_missing_artifact_returns_not_found() {
159        let tmp = tempfile::tempdir().expect("tempdir");
160        init_test_repo(tmp.path());
161
162        let err = load_handoff_artifact(tmp.path(), "missing-artifact").unwrap_err();
163        assert!(matches!(err, GitStorageError::NotFound(_)));
164    }
165}