opensession_git_native/
handoff_artifact_store.rs1use 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}