git_remote_object_store/manage/
gc.rs1use std::io::Write;
24use std::sync::Arc;
25
26use tracing::info;
27
28use super::ManageError;
29use super::gc_output::{format_mark_outcome, format_sweep_outcome};
30use crate::object_store::ObjectStore;
31use crate::packchain::gc;
32
33#[derive(Debug, Clone, Copy, Default)]
35pub struct GcOpts {
36 pub mode: GcMode,
41 pub force: bool,
44 pub grace_hours: Option<u64>,
49}
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub enum GcMode {
59 #[default]
63 Default,
64 MarkOnly,
66 SweepOnly,
68}
69
70pub struct Gc {
72 store: Arc<dyn ObjectStore>,
73 prefix: String,
74 opts: GcOpts,
75}
76
77impl Gc {
78 #[must_use]
82 pub fn new(store: Arc<dyn ObjectStore>, prefix: impl Into<String>, opts: GcOpts) -> Self {
83 Self {
84 store,
85 prefix: prefix.into(),
86 opts,
87 }
88 }
89
90 pub async fn run(&self) -> Result<(), ManageError> {
98 self.run_with_writer(&mut std::io::stdout()).await
106 }
107
108 async fn run_with_writer<W: Write>(&self, out: &mut W) -> Result<(), ManageError> {
109 let store_ref = self.store.as_ref();
110
111 if self.opts.mode != GcMode::SweepOnly {
112 let mark_outcome = gc::mark(store_ref, &self.prefix, gc::MarkOpts::default()).await?;
113 format_mark_outcome(out, &mark_outcome)?;
114 if mark_outcome.orphan_count != 0 {
115 info!(
116 run_id = %mark_outcome.run_id,
117 key = %mark_outcome.tombstone_key,
118 "gc mark completed",
119 );
120 }
121 }
122
123 if self.opts.mode != GcMode::MarkOnly {
124 let grace_hours = gc::resolve_grace_hours(self.opts.grace_hours);
125 let sweep_outcome = gc::sweep(
126 store_ref,
127 &self.prefix,
128 gc::SweepOpts {
129 grace_hours,
130 force: self.opts.force,
131 },
132 )
133 .await?;
134 format_sweep_outcome(out, &sweep_outcome)?;
135 }
136 Ok(())
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::git::RefName;
144 use crate::object_store::mock::MockStore;
145 use crate::packchain::manifest::write_chain;
146 use crate::packchain::schema::{ChainManifest, ChainSegment, Sha40};
147 use bytes::Bytes;
148
149 const SHA_TIP: &str = "0000000000000000000000000000000000000001";
150 const SHA_PACK_LIVE: &str = "1111111111111111111111111111111111111111";
151 const SHA_PACK_ORPHAN: &str = "2222222222222222222222222222222222222222";
152
153 fn sha40(s: &str) -> Sha40 {
154 Sha40::try_new(s).unwrap()
155 }
156
157 fn ref_main() -> RefName {
158 RefName::new("refs/heads/main").unwrap()
159 }
160
161 async fn seed_state(store: &MockStore, prefix: Option<&str>) {
162 let chain = ChainManifest {
163 v: 1,
164 tip: sha40(SHA_TIP),
165 full_at: sha40(SHA_TIP),
166 segments: vec![ChainSegment {
167 sha: sha40(SHA_TIP),
168 parent_sha: None,
169 pack: format!("packs/{SHA_PACK_LIVE}.pack"),
170 bytes: 1_024,
171 }],
172 };
173 write_chain(store, prefix, &ref_main(), &chain)
174 .await
175 .unwrap();
176 let live_pack = crate::packchain::keys::pack_key(prefix, &sha40(SHA_PACK_LIVE));
177 let live_idx = crate::packchain::keys::pack_idx_key(prefix, &sha40(SHA_PACK_LIVE));
178 store.insert(live_pack, Bytes::from_static(b"PACK"));
179 store.insert(live_idx, Bytes::from_static(b"IDX"));
180 let orphan_pack = crate::packchain::keys::pack_key(prefix, &sha40(SHA_PACK_ORPHAN));
181 let orphan_idx = crate::packchain::keys::pack_idx_key(prefix, &sha40(SHA_PACK_ORPHAN));
182 store.insert(orphan_pack, Bytes::from_static(b"PACK"));
183 store.insert(orphan_idx, Bytes::from_static(b"IDX"));
184 }
185
186 #[tokio::test]
187 async fn run_mark_only_writes_tombstone_without_sweep() {
188 let store = Arc::new(MockStore::new());
189 seed_state(&store, Some("repo")).await;
190 let gc = Gc::new(
191 Arc::clone(&store) as Arc<dyn ObjectStore>,
192 "repo",
193 GcOpts {
194 mode: GcMode::MarkOnly,
195 ..GcOpts::default()
196 },
197 );
198 gc.run().await.unwrap();
199 let metas = store.list("repo/gc/").await.unwrap();
201 assert_eq!(metas.len(), 1, "exactly one tombstone after mark-only");
202 store
203 .get_bytes(&format!("repo/packs/{SHA_PACK_ORPHAN}.pack"))
204 .await
205 .expect("orphan pack must survive mark-only");
206 }
207
208 #[tokio::test]
209 async fn run_sweep_only_with_force_deletes_orphans() {
210 let store = Arc::new(MockStore::new());
211 seed_state(&store, Some("repo")).await;
212 gc::mark(store.as_ref(), "repo", gc::MarkOpts::default())
214 .await
215 .unwrap();
216 let gc = Gc::new(
218 Arc::clone(&store) as Arc<dyn ObjectStore>,
219 "repo",
220 GcOpts {
221 mode: GcMode::SweepOnly,
222 force: true,
223 ..GcOpts::default()
224 },
225 );
226 gc.run().await.unwrap();
227 let err = store
229 .get_bytes(&format!("repo/packs/{SHA_PACK_ORPHAN}.pack"))
230 .await
231 .unwrap_err();
232 assert!(matches!(
233 err,
234 crate::object_store::ObjectStoreError::NotFound(_)
235 ));
236 store
237 .get_bytes(&format!("repo/packs/{SHA_PACK_LIVE}.pack"))
238 .await
239 .unwrap();
240 }
241
242 #[tokio::test]
243 async fn run_mark_then_sweep_force_round_trips() {
244 let store = Arc::new(MockStore::new());
245 seed_state(&store, Some("repo")).await;
246 let gc = Gc::new(
247 Arc::clone(&store) as Arc<dyn ObjectStore>,
248 "repo",
249 GcOpts {
250 force: true,
251 ..GcOpts::default()
252 },
253 );
254 gc.run().await.unwrap();
255 let err = store
257 .get_bytes(&format!("repo/packs/{SHA_PACK_ORPHAN}.pack"))
258 .await
259 .unwrap_err();
260 assert!(matches!(
261 err,
262 crate::object_store::ObjectStoreError::NotFound(_)
263 ));
264 }
265
266 #[tokio::test]
267 async fn run_with_no_orphans_is_noop() {
268 let store = Arc::new(MockStore::new());
269 let chain = ChainManifest {
271 v: 1,
272 tip: sha40(SHA_TIP),
273 full_at: sha40(SHA_TIP),
274 segments: vec![ChainSegment {
275 sha: sha40(SHA_TIP),
276 parent_sha: None,
277 pack: format!("packs/{SHA_PACK_LIVE}.pack"),
278 bytes: 1_024,
279 }],
280 };
281 write_chain(store.as_ref(), Some("repo"), &ref_main(), &chain)
282 .await
283 .unwrap();
284 let live_pack = crate::packchain::keys::pack_key(Some("repo"), &sha40(SHA_PACK_LIVE));
285 store.insert(live_pack, Bytes::from_static(b"PACK"));
286 let gc = Gc::new(
287 Arc::clone(&store) as Arc<dyn ObjectStore>,
288 "repo",
289 GcOpts::default(),
290 );
291 gc.run().await.unwrap();
292 let metas = store.list("repo/gc/").await.unwrap();
293 assert!(metas.is_empty(), "no tombstone when no orphans");
294 }
295}