Skip to main content

submilli_engine/stdlib/git/
mod.rs

1//! Capability-controlled Git operations. No subprocesses or guest-visible credentials.
2use crate::runtime::host::{abi_arg, abi_result};
3pub(crate) mod class;
4pub mod declaration;
5mod diff;
6mod history;
7mod index_limits;
8mod location;
9mod lock;
10pub(crate) mod log_cache;
11mod metadata_scan;
12mod meter;
13mod object;
14mod operations;
15mod pack_index_check;
16mod pack_limits;
17mod stage;
18mod storage;
19mod transport;
20mod work;
21mod worker;
22
23use crate::runtime::decision::{CallSite, EntryPath, SourceLine};
24use crate::runtime::fuel;
25use crate::runtime::host::{
26    permission_denied, read_string_arg, register_host_fn_async, write_submilli_string_struct,
27    write_submilli_uint8array_struct,
28};
29use crate::runtime::intrinsic_types::{IntrinsicTypes, build_intrinsic_types};
30use crate::runtime::{HttpClient, SecretProvider, SecurityCheck, StoreData};
31use crate::stdlib::{session::value, shared::running_package};
32use serde_json::Value;
33use std::sync::{
34    Arc, Mutex, OnceLock,
35    atomic::{AtomicBool, AtomicU64, Ordering},
36};
37use wasmtime::{Caller, FuncType, HeapType, Linker, RefType, Result, Val, ValType, bail};
38
39static WORKERS: OnceLock<Arc<tokio::sync::Semaphore>> = OnceLock::new();
40
41pub const MODULE_NAME: &str = "submilli:git";
42pub use declaration::package_declaration;
43pub(crate) use transport::canonical_url;
44
45#[derive(Clone, Debug)]
46pub struct GitConfig {
47    pub name: String,
48    pub email: String,
49    pub username: Option<String>,
50}
51
52#[derive(Clone)]
53struct Job {
54    op: String,
55    path: String,
56    caller: String,
57    config: GitConfig,
58    security: Arc<dyn SecurityCheck>,
59    secrets: Arc<dyn SecretProvider>,
60    http: Arc<dyn HttpClient>,
61    runtime: tokio::runtime::Handle,
62    /// When the operation times out, waits included.
63    deadline: tokio::time::Instant,
64    cancelled: Arc<AtomicBool>,
65    max_bytes: u64,
66    transferred: Arc<AtomicU64>,
67    /// The worker's own file and object work, charged when it returns.
68    meter: Arc<meter::Meter>,
69    algorithm_fuel: Arc<work::AlgorithmWork>,
70    denial: Arc<Mutex<Option<(String, String)>>>,
71    history_cache: Option<Arc<log_cache::Cache>>,
72    /// The program line that started the operation, captured only while recording.
73    line: Option<SourceLine>,
74}
75impl Job {
76    fn remote_capability(&self) -> &'static str {
77        if self.op == "clone" {
78            "git.clone"
79        } else {
80            "git.fetch"
81        }
82    }
83
84    fn check(&self, capability: &str, context: Value) -> Result<()> {
85        self.check_cancelled()?;
86        // Git's decisions are made on a worker thread, after the program's stack is gone;
87        // they reuse the line the host call captured on entry.
88        let site = CallSite::new(
89            self.security
90                .recorder()
91                .map(|recorder| recorder.begin_call(&self.caller, capability, self.line)),
92            EntryPath::Git,
93        );
94        crate::stdlib::shared::check_and_audit(
95            self.security.as_ref(),
96            &self.caller,
97            capability,
98            &context,
99            "/",
100            site,
101        )
102        .map_err(|reason| {
103            // Poison may leave the denied capability and reason partly updated.
104            // AGENTS.md permits poisoned-lock panics rather than recovering this
105            // attribution; it does not permit the panic that caused poisoning.
106            let mut denial = self.denial.lock().expect("git worker denial lock poisoned");
107            *denial = Some((capability.to_owned(), reason.clone()));
108            permission_denied(&self.caller, capability, reason)
109        })
110    }
111
112    /// One of the workers Git shares across the process, waited for until
113    /// the operation's deadline or its cancellation.
114    fn worker_permit(&self) -> Result<tokio::sync::OwnedSemaphorePermit> {
115        let workers = WORKERS
116            .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4)))
117            .clone();
118        // Polled from the worker's own thread, which is not the runtime's.
119        loop {
120            match workers.clone().try_acquire_owned() {
121                Ok(permit) => return Ok(permit),
122                Err(tokio::sync::TryAcquireError::Closed) => {
123                    return Err(crate::runtime::host::fatal_host_error(
124                        "git: workers closed",
125                    ));
126                }
127                Err(tokio::sync::TryAcquireError::NoPermits) => {}
128            }
129            self.check_cancelled()?;
130            if tokio::time::Instant::now() >= self.deadline {
131                bail!("git: operation timed out");
132            }
133            std::thread::sleep(std::time::Duration::from_millis(10));
134        }
135    }
136
137    fn check_cancelled(&self) -> Result<()> {
138        if self.cancelled.load(Ordering::Relaxed) {
139            bail!("git: operation cancelled");
140        }
141        Ok(())
142    }
143}
144struct CancelOnDrop(Arc<AtomicBool>);
145impl Drop for CancelOnDrop {
146    fn drop(&mut self) {
147        self.0.store(true, Ordering::Relaxed);
148    }
149}
150
151pub fn install(linker: &mut Linker<StoreData>) -> Result<()> {
152    let engine = linker.engine().clone();
153    let intr = build_intrinsic_types(&engine)?;
154    install_repository_reads(linker, &engine, &intr)?;
155    install_repository_writes(linker, &engine, &intr)
156}
157
158fn install_repository_reads(
159    linker: &mut Linker<StoreData>,
160    engine: &wasmtime::Engine,
161    intr: &IntrinsicTypes,
162) -> Result<()> {
163    let argument = ValType::Ref(RefType::new(
164        true,
165        HeapType::ConcreteStruct(intr.object.clone()),
166    ));
167    let receiver = ValType::Ref(RefType::new(
168        false,
169        HeapType::ConcreteStruct(intr.object.clone()),
170    ));
171    let object = ValType::Ref(RefType::new(
172        false,
173        HeapType::ConcreteStruct(intr.object_shape.clone()),
174    ));
175    let bytes = ValType::Ref(RefType::new(
176        false,
177        HeapType::ConcreteStruct(intr.uint8_array.clone()),
178    ));
179    let array = ValType::Ref(RefType::new(
180        false,
181        HeapType::ConcreteStruct(intr.array.clone()),
182    ));
183    let repository = crate::mangle::package_symbol(MODULE_NAME, "Repository");
184    register_host_fn_async(
185        linker,
186        MODULE_NAME,
187        crate::mangle::extend(&repository, "status"),
188        FuncType::new(engine, [receiver.clone()], [object.clone()]),
189        false,
190        |caller, params, results| {
191            Box::pin(async move {
192                *abi_result(results, 0)? = invoke(caller, "status", true, params).await?;
193                Ok(())
194            })
195        },
196    )?;
197    register_host_fn_async(
198        linker,
199        MODULE_NAME,
200        crate::mangle::extend(&repository, "log"),
201        FuncType::new(
202            engine,
203            [receiver.clone(), argument.clone()],
204            [object.clone()],
205        ),
206        false,
207        |caller, params, results| {
208            Box::pin(async move {
209                *abi_result(results, 0)? = invoke(caller, "log", true, params).await?;
210                Ok(())
211            })
212        },
213    )?;
214    register_host_fn_async(
215        linker,
216        MODULE_NAME,
217        crate::mangle::extend(&repository, "diff"),
218        FuncType::new(
219            engine,
220            [receiver.clone(), argument.clone()],
221            [object.clone()],
222        ),
223        false,
224        |caller, params, results| {
225            Box::pin(async move {
226                *abi_result(results, 0)? = invoke(caller, "diff", true, params).await?;
227                Ok(())
228            })
229        },
230    )?;
231    register_host_fn_async(
232        linker,
233        MODULE_NAME,
234        crate::mangle::extend(&repository, "show"),
235        FuncType::new(
236            engine,
237            [receiver.clone(), argument.clone(), argument.clone()],
238            [bytes.clone()],
239        ),
240        false,
241        |caller, params, results| {
242            Box::pin(async move {
243                *abi_result(results, 0)? = invoke(caller, "show", true, params).await?;
244                Ok(())
245            })
246        },
247    )?;
248    register_host_fn_async(
249        linker,
250        MODULE_NAME,
251        crate::mangle::extend(&repository, "branches"),
252        FuncType::new(engine, [receiver.clone()], [array.clone()]),
253        false,
254        |caller, params, results| {
255            Box::pin(async move {
256                *abi_result(results, 0)? = invoke(caller, "branches", true, params).await?;
257                Ok(())
258            })
259        },
260    )?;
261    register_host_fn_async(
262        linker,
263        MODULE_NAME,
264        crate::mangle::extend(&repository, "remotes"),
265        FuncType::new(engine, [receiver.clone()], [array.clone()]),
266        false,
267        |caller, params, results| {
268            Box::pin(async move {
269                *abi_result(results, 0)? = invoke(caller, "remotes", true, params).await?;
270                Ok(())
271            })
272        },
273    )?;
274    Ok(())
275}
276
277fn install_repository_writes(
278    linker: &mut Linker<StoreData>,
279    engine: &wasmtime::Engine,
280    intr: &IntrinsicTypes,
281) -> Result<()> {
282    let argument = ValType::Ref(RefType::new(
283        true,
284        HeapType::ConcreteStruct(intr.object.clone()),
285    ));
286    let receiver = ValType::Ref(RefType::new(
287        false,
288        HeapType::ConcreteStruct(intr.object.clone()),
289    ));
290    let object = ValType::Ref(RefType::new(
291        false,
292        HeapType::ConcreteStruct(intr.object_shape.clone()),
293    ));
294    // Class method slots carry a value even when their source return type is void.
295    let completion = argument.clone();
296    let repository = crate::mangle::package_symbol(MODULE_NAME, "Repository");
297    register_host_fn_async(
298        linker,
299        MODULE_NAME,
300        crate::mangle::extend(&repository, "add"),
301        FuncType::new(
302            engine,
303            [receiver.clone(), argument.clone()],
304            [completion.clone()],
305        ),
306        false,
307        |caller, params, results| {
308            Box::pin(async move {
309                invoke(caller, "add", true, params).await?;
310                *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
311                Ok(())
312            })
313        },
314    )?;
315    register_host_fn_async(
316        linker,
317        MODULE_NAME,
318        crate::mangle::extend(&repository, "commit"),
319        FuncType::new(
320            engine,
321            [receiver.clone(), argument.clone()],
322            [ValType::Ref(RefType::new(
323                false,
324                intr.string.clone().into(),
325            ))],
326        ),
327        false,
328        |caller, params, results| {
329            Box::pin(async move {
330                *abi_result(results, 0)? = invoke(caller, "commit", true, params).await?;
331                Ok(())
332            })
333        },
334    )?;
335    register_host_fn_async(
336        linker,
337        MODULE_NAME,
338        crate::mangle::extend(&repository, "createBranch"),
339        FuncType::new(
340            engine,
341            [receiver.clone(), argument.clone(), argument.clone()],
342            [completion.clone()],
343        ),
344        false,
345        |caller, params, results| {
346            Box::pin(async move {
347                invoke(caller, "createBranch", true, params).await?;
348                *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
349                Ok(())
350            })
351        },
352    )?;
353    register_host_fn_async(
354        linker,
355        MODULE_NAME,
356        crate::mangle::extend(&repository, "switchBranch"),
357        FuncType::new(
358            engine,
359            [receiver.clone(), argument.clone()],
360            [completion.clone()],
361        ),
362        false,
363        |caller, params, results| {
364            Box::pin(async move {
365                invoke(caller, "switchBranch", true, params).await?;
366                *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
367                Ok(())
368            })
369        },
370    )?;
371    register_host_fn_async(
372        linker,
373        MODULE_NAME,
374        crate::mangle::extend(&repository, "addRemote"),
375        FuncType::new(
376            engine,
377            [receiver.clone(), argument.clone(), argument.clone()],
378            [completion.clone()],
379        ),
380        false,
381        |caller, params, results| {
382            Box::pin(async move {
383                invoke(caller, "addRemote", true, params).await?;
384                *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
385                Ok(())
386            })
387        },
388    )?;
389    register_host_fn_async(
390        linker,
391        MODULE_NAME,
392        crate::mangle::extend(&repository, "setRemoteUrl"),
393        FuncType::new(
394            engine,
395            [receiver.clone(), argument.clone(), argument.clone()],
396            [completion.clone()],
397        ),
398        false,
399        |caller, params, results| {
400            Box::pin(async move {
401                invoke(caller, "setRemoteUrl", true, params).await?;
402                *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
403                Ok(())
404            })
405        },
406    )?;
407    register_host_fn_async(
408        linker,
409        MODULE_NAME,
410        crate::mangle::extend(&repository, "fetch"),
411        FuncType::new(
412            engine,
413            [receiver.clone(), argument.clone(), argument.clone()],
414            [object.clone()],
415        ),
416        false,
417        |caller, params, results| {
418            Box::pin(async move {
419                *abi_result(results, 0)? = invoke(caller, "fetch", true, params).await?;
420                Ok(())
421            })
422        },
423    )?;
424    register_host_fn_async(
425        linker,
426        MODULE_NAME,
427        crate::mangle::extend(&repository, "pull"),
428        FuncType::new(
429            engine,
430            [receiver.clone(), argument.clone(), argument.clone()],
431            [object.clone()],
432        ),
433        false,
434        |caller, params, results| {
435            Box::pin(async move {
436                *abi_result(results, 0)? = invoke(caller, "pull", true, params).await?;
437                Ok(())
438            })
439        },
440    )?;
441    Ok(())
442}
443
444async fn invoke(
445    caller: &mut Caller<'_, StoreData>,
446    op: &str,
447    method: bool,
448    params: &[Val],
449) -> Result<Val> {
450    let deadline = tokio::time::Instant::now()
451        .checked_add(std::time::Duration::from_secs(60))
452        .ok_or_else(|| crate::runtime::host::fatal_host_error("git deadline is out of range"))?;
453    let config = caller.data().git.clone().ok_or_else(|| {
454        wasmtime::Error::msg("submilli:git is disabled; configure the blueprint git block")
455    })?;
456    if op == "log" && caller.data().git_history.is_none() {
457        let cache = log_cache::Cache::new(&caller.data().tenant_limits)?;
458        caller.data_mut().git_history = Some(Arc::new(cache));
459    }
460    if matches!(
461        op,
462        "init" | "clone" | "add" | "commit" | "createBranch" | "switchBranch" | "fetch" | "pull"
463    ) && let Some(cache) = &caller.data().git_history
464    {
465        cache.invalidate()?;
466    }
467    let budget = WorkingBudget::reserve(&caller.data().tenant_limits, op)?;
468    let max_bytes = budget.max_bytes(op);
469    let (args, path) = before_deadline(
470        deadline,
471        decode_arguments(caller, op, method, params, max_bytes),
472    )
473    .await??;
474    let principal = running_package(&*caller)
475        .map_err(|_| crate::runtime::host::fatal_host_error("git: cannot identify caller"))?;
476    let cancelled = Arc::new(AtomicBool::new(false));
477    let cancel_guard = CancelOnDrop(cancelled.clone());
478    // The worker stops itself once its work passes what the run has left.
479    let meter = Arc::new(meter::Meter::new(
480        meter::remaining_fuel(&mut *caller)?,
481        Some(cancelled.clone()),
482    ));
483    let job = Job {
484        op: op.to_owned(),
485        path: path.clone(),
486        caller: principal,
487        config,
488        security: caller.data().security_check.clone(),
489        secrets: caller.data().secret_provider.clone(),
490        http: caller.data().http_client.clone(),
491        runtime: tokio::runtime::Handle::try_current().map_err(|_| {
492            crate::runtime::host::fatal_host_error("git worker requires a Tokio runtime")
493        })?,
494        deadline,
495        cancelled,
496        max_bytes,
497        transferred: Arc::new(AtomicU64::new(0)),
498        meter: Arc::clone(&meter),
499        algorithm_fuel: Arc::new(work::AlgorithmWork::with_meter(Arc::clone(&meter))),
500        denial: Arc::new(Mutex::new(None)),
501        history_cache: caller.data().git_history.clone(),
502        line: crate::stdlib::shared::source_line(&*caller),
503    };
504    let vfs = caller.data().vfs.clone();
505    let op = op.to_owned();
506    let is_constructor = !method;
507    let returns_string = op == "commit";
508    let denial = job.denial.clone();
509    let principal = job.caller.clone();
510    let worker = async {
511        caller
512            .data_mut()
513            .blocking_work
514            .spawn(move || {
515                job.check_cancelled()?;
516                worker::run(&vfs, &job, &op, &args).map(|result| (result, budget))
517            })
518            .await
519            .map_err(blocking_worker_failure)?
520    };
521    let outcome = finish_worker(worker, &cancel_guard.0, deadline).await;
522    // The work is done, and may be published, whether or not it succeeded:
523    // it is settled, not refused.
524    meter.settle(&mut *caller)?;
525    if let Err(error) = &outcome
526        && meter.is_exhausted()
527        && !crate::runtime::host::ends_the_run(error)
528        && !error.is::<stage::NeedsHostRecovery>()
529    {
530        // Stopped for fuel before publishing: the run ends as a Wasm loop
531        // out of fuel would, and the program cannot catch it. A host failure,
532        // or a publication that needs recovery, says more and goes first.
533        return Err(wasmtime::Trap::OutOfFuel.into());
534    }
535
536    let outcome = match outcome {
537        Err(error)
538            if crate::runtime::host::ends_the_run(&error)
539                || error.is::<stage::NeedsHostRecovery>() =>
540        {
541            return Err(error);
542        }
543        other => other,
544    };
545    // Poison may leave denial attribution partly updated by the worker. AGENTS.md
546    // permits this poisoned-lock panic instead of recovery, including after worker
547    // cleanup; it does not permit the panic that caused poisoning.
548    let denial = denial.lock().expect("git: denial lock poisoned").take();
549    drop(cancel_guard);
550    fuel::settle_result(caller, |caller| {
551        if let Some((capability, reason)) = denial {
552            return Err(crate::runtime::host::throw_host_error(
553                caller,
554                permission_denied(&principal, &capability, reason),
555            ));
556        }
557        let (result, _budget) =
558            outcome.map_err(|error| crate::runtime::host::throw_host_error(caller, error))?;
559        encode_result(
560            caller,
561            result,
562            &path,
563            is_constructor,
564            returns_string,
565            max_bytes,
566        )
567        .map_err(|error| crate::runtime::host::throw_host_error(caller, error))
568    })
569}
570
571async fn before_deadline<T>(
572    deadline: tokio::time::Instant,
573    future: impl std::future::Future<Output = T>,
574) -> Result<T> {
575    if tokio::time::Instant::now() >= deadline {
576        bail!("git: operation timed out");
577    }
578    tokio::time::timeout_at(deadline, future)
579        .await
580        .map_err(|_| wasmtime::Error::msg("git: operation timed out"))
581}
582
583async fn finish_worker<T>(
584    worker: impl std::future::Future<Output = Result<T>>,
585    cancelled: &AtomicBool,
586    deadline: tokio::time::Instant,
587) -> Result<T> {
588    tokio::pin!(worker);
589    match before_deadline(deadline, &mut worker).await {
590        Ok(result) => result,
591        Err(error) => {
592            cancelled.store(true, Ordering::Relaxed);
593            // A catchable timeout must not let guest code resume while the
594            // worker is still publishing or rolling back repository changes.
595            match worker.await {
596                Err(worker_error)
597                    if crate::runtime::host::ends_the_run(&worker_error)
598                        || worker_error.is::<stage::NeedsHostRecovery>() =>
599                {
600                    Err(worker_error)
601                }
602                _ => Err(error),
603            }
604        }
605    }
606}
607
608fn blocking_worker_failure(error: crate::runtime::blocking::BlockingWorkError) -> wasmtime::Error {
609    crate::runtime::host::fatal_host_error("git blocking worker failed").context(error)
610}
611
612async fn decode_arguments(
613    caller: &mut Caller<'_, StoreData>,
614    op: &str,
615    method: bool,
616    params: &[Val],
617    max_bytes: u64,
618) -> Result<(Vec<Value>, String)> {
619    let mut args = Vec::new();
620    let offset = usize::from(method);
621    for (index, param) in params
622        .get(offset..)
623        .ok_or_else(|| crate::runtime::host::invariant_trap("git ABI: missing arguments"))?
624        .iter()
625        .enumerate()
626    {
627        if crate::runtime::prelude::undefined::is_undefined(caller, param)? {
628            args.push(undefined_argument(op, index)?);
629            continue;
630        }
631        let units = value::serialize(caller, param).await?;
632        if units.len() as u64 * 2 > max_bytes {
633            bail!("git: argument size limit exceeded");
634        }
635        fuel::charge(&mut *caller, fuel::PARSE, units.len() as u64)?;
636        let text = String::from_utf16(&units)
637            .map_err(|_| wasmtime::Error::msg("git: arguments must be valid Unicode"))?;
638        args.push(serde_json::from_str::<Value>(&text)?);
639    }
640    let path = if method {
641        let field = class::path(caller, abi_arg(params, 0)?)?;
642        read_string_arg(&mut *caller, &field, "git repository")?
643    } else {
644        operations::text_arg(&args, usize::from(op == "clone"))?.to_owned()
645    };
646    let path = crate::runtime::fs::guest_normalize(caller.data().vfs.cwd(), &path)?;
647    if path
648        .split('/')
649        .any(|part| part.eq_ignore_ascii_case(".git") || stage::is_reserved_stage_name(part))
650    {
651        bail!("git: repository path targets protected metadata");
652    }
653    Ok((args, path))
654}
655
656// Virtual calls apply defaults in the selected method, just as guest classes do.
657fn undefined_argument(op: &str, index: usize) -> Result<Value> {
658    match (op, index) {
659        ("init", 1) | ("clone", 2) | ("log" | "diff", 0) => {
660            Ok(Value::Object(serde_json::Map::new()))
661        }
662        ("createBranch", 1) => Ok(Value::String("HEAD".into())),
663        ("fetch" | "pull", 0) => Ok(Value::String("origin".into())),
664        ("fetch" | "pull", 1) => Ok(Value::String(String::new())),
665        _ => bail!("git: undefined is not accepted for this argument to {op}"),
666    }
667}
668
669fn encode_result(
670    caller: &mut Caller<'_, StoreData>,
671    result: worker::Output,
672    path: &str,
673    is_constructor: bool,
674    returns_string: bool,
675    max_bytes: u64,
676) -> Result<Val> {
677    if is_constructor {
678        let path = write_submilli_string_struct(caller, path)?;
679        return Ok(Val::AnyRef(Some(path.to_anyref())));
680    }
681    let result = match result {
682        worker::Output::Bytes(bytes) => {
683            let value = write_submilli_uint8array_struct(caller, &bytes)?;
684            return Ok(Val::AnyRef(Some(value.to_anyref())));
685        }
686        worker::Output::Json(value) => value,
687    };
688    if returns_string {
689        let text = result
690            .as_str()
691            .ok_or_else(|| wasmtime::Error::msg("git: invalid string result"))?;
692        let value = write_submilli_string_struct(caller, text)?;
693        return Ok(Val::AnyRef(Some(value.to_anyref())));
694    }
695    let text = serde_json::to_string(&result)?;
696    if text.len() > max_bytes as usize {
697        bail!("git: result limit exceeded");
698    }
699    // Serialized and parsed back; the operation is already published.
700    fuel::settle(&mut *caller, fuel::PARSE, 2 * text.len() as u64)?;
701    value::deserialize(caller, &text.encode_utf16().collect::<Vec<_>>())
702}
703
704/// The least Git works with: what opening a repository and a fetch's index
705/// buffers take, measured at under 1 MB, with room to spare.
706const MIN_WORKING_BYTES: u64 = 4 * 1024 * 1024;
707
708/// Working memory reserved for one Git operation against the same cap as
709/// Wasm, while the program waits for it.
710///
711/// gix runs natively, outside the metered heap, so the operation reserves
712/// three quarters of what the run has free, and bounds what it holds by
713/// counting: trees, the index, worktree paths, a blob, a diff, a fetched
714/// pack's delta chains. Each count is held to the reservation divided by
715/// what the operation was measured to need per byte counted, beyond its
716/// fixed working set (`tests/git_memory.rs`).
717struct WorkingBudget {
718    bytes: u64,
719    counter: Arc<std::sync::atomic::AtomicU64>,
720}
721impl WorkingBudget {
722    fn reserve(limits: &crate::runtime::limits::TenantLimits, op: &str) -> Result<Self> {
723        let available = limits
724            .max_total_bytes
725            .saturating_sub(limits.observed_bytes())
726            .saturating_sub(limits.host_attached_bytes());
727        let bytes = available / 4 * 3;
728        if bytes < MIN_WORKING_BYTES {
729            const MB: u64 = 1024 * 1024;
730            bail!(
731                "git.{op} needs at least {} MB of working memory; {} MB is free under \
732                 max_execution_memory ({} MB). Raise max_execution_memory",
733                MIN_WORKING_BYTES * 4 / 3 / MB,
734                available / MB,
735                limits.max_total_bytes / MB
736            );
737        }
738        limits.charge_host_bytes(bytes)?;
739        Ok(Self {
740            bytes,
741            counter: limits.host_attached_counter(),
742        })
743    }
744
745    /// What `op` may count while it works: the reservation divided by what
746    /// it was measured to hold per byte counted. A diff holds its patch two
747    /// or three times over as it is built and encoded; everything else
748    /// holds at most about three times what it counts.
749    fn max_bytes(&self, op: &str) -> u64 {
750        let ratio = if op == "diff" { 8 } else { 4 };
751        (self.bytes.saturating_sub(MIN_WORKING_BYTES / 4) / ratio).min(storage::MAX_WORKING_BYTES)
752    }
753}
754impl Drop for WorkingBudget {
755    fn drop(&mut self) {
756        self.counter.fetch_sub(self.bytes, Ordering::Relaxed);
757    }
758}
759
760#[cfg(test)]
761mod tests;
762
763#[cfg(test)]
764mod integration_tests;