AkurAI Build
Menu

Public repository / a0c9d4ff8c1ee8397ccd5287465783b1f0f23d04

AkurAI-Build / src/repo_delete.rs

Latest change 40a6aea9365ad1de0b4db056536c1fde2ec8cb9f - Repository deletion: DELETE /api/repos/{repository}?confirm={repository} by Ólafur Búi Ólafsson

//! Deleting a repository from the forge: its database rows first (atomic,
//! refused while a run is queued or running), then the files those rows
//! owned under the data root — the hosted bare repository, the cached browse
//! mirror, and each run's artifact store and workspace.

use std::{
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use serde::Serialize;

use crate::{
    db::{self, Database, RepositoryDeletion},
    hosted_git, tree,
};

#[derive(Debug, Serialize)]
pub struct RepositoryRemoval {
    #[serde(flatten)]
    pub deleted: RepositoryDeletion,
    /// Directories removed from disk, absolute.
    pub removed_paths: Vec<PathBuf>,
}

pub fn delete(database: &Database, data_root: &Path, name: &str) -> Result<RepositoryRemoval> {
    // Validates the name before anything is deleted.
    let hosted = hosted_git::repository_path(data_root, name)?;
    let deleted = database.delete_repository(name)?;
    let (_, workspaces, artifacts) = db::default_paths(data_root);
    let run_dirs = deleted.run_ids.iter().flat_map(|id| {
        [
            artifacts.join(id.to_string()),
            workspaces.join(id.to_string()),
        ]
    });
    let mut removed_paths = Vec::new();
    for path in [hosted, tree::mirror_dir(data_root, name)]
        .into_iter()
        .chain(run_dirs)
    {
        if path.is_dir() {
            fs::remove_dir_all(&path).with_context(|| {
                format!(
                    "repository {name} rows are deleted but {} could not be removed",
                    path.display()
                )
            })?;
            removed_paths.push(path);
        }
    }
    Ok(RepositoryRemoval {
        deleted,
        removed_paths,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";

    #[test]
    fn delete_removes_rows_and_owned_directories_only() -> Result<()> {
        let root = tempfile::tempdir()?;
        let database = Database::memory(KEY)?;
        let gone = database.add_repository("gone", "https://example.com/gone.git", "main")?;
        let kept = database.add_repository("kept", "https://example.com/kept.git", "main")?;
        let run = database.create_run(gone.id, "main", None, "manual")?;
        database.finish_run(run, "succeeded", None)?;
        let kept_run = database.create_run(kept.id, "main", None, "manual")?;
        let owned = [
            root.path().join("hosted/gone.git"),
            root.path().join("mirrors/gone.git"),
            root.path().join(format!("artifacts/{run}/1")),
            root.path().join(format!("workspaces/{run}")),
        ];
        let untouched = [
            root.path().join("hosted/kept.git"),
            root.path().join(format!("artifacts/{kept_run}")),
        ];
        for path in owned.iter().chain(&untouched) {
            fs::create_dir_all(path)?;
        }

        let removal = delete(&database, root.path(), "gone")?;

        assert_eq!(removal.deleted.run_ids, vec![run]);
        assert_eq!(removal.removed_paths.len(), 4);
        assert!(
            owned.iter().all(|path| !path.exists()),
            "owned dirs removed"
        );
        assert!(untouched.iter().all(|path| path.is_dir()), "others kept");
        assert!(database.repository("gone").is_err());
        assert!(database.repository("kept").is_ok());
        Ok(())
    }

    #[test]
    fn delete_rejects_invalid_names_before_touching_anything() -> Result<()> {
        let root = tempfile::tempdir()?;
        let database = Database::memory(KEY)?;
        assert!(delete(&database, root.path(), "../escape").is_err());
        Ok(())
    }
}