Skip to content
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ name = "coduck-backend"
anyhow = "1.0"
axum = { version = "0.8.4", features = ["json", "multipart"] }
chrono = { version = "0.4.38", features = ["serde"] }
reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] }
git2 = "0.20.2"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.133"
tokio = { version = "1.45.1", features = ["full"] }
uuid = { version = "1.17.0", features = ["v4"] }

[dev-dependencies]
reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] }
rstest = "0.25.0"
378 changes: 378 additions & 0 deletions src/file_manager/git.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,378 @@
#![allow(dead_code)]

use anyhow::{Context, Result};
use git2::{DiffOptions, IndexAddOption, Repository, StatusOptions, Time};
use std::path::PathBuf;
use tokio::fs;

const UPLOAD_DIR: &str = "uploads";

#[derive(Debug)]
struct GitManager {
problem_id: u32,
}

impl GitManager {
fn new(problem_id: u32) -> Self {
Self { problem_id }
}

fn git_init(&self) -> Result<()> {
let path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string());
Repository::init(&path)
.map(|_| ())
.with_context(|| format!("Failed to init git repo at {:?}", path))
}

async fn create_problem(&self) -> Result<()> {
self.git_init()?;
self.create_default_directories().await?;
self.git_add_all()?;
Ok(())
}

fn git_add_all(&self) -> Result<()> {
let repo = self.get_repository()?;
let mut idx = repo.index()?;
idx.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?;
idx.write()?;
Ok(())
}

fn git_commit(&self, message: String) -> Result<String> {
self.git_add_all()?;
let repo = self.get_repository()?;
let mut idx = repo.index()?;
let tree_id = idx.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let sig = repo.signature()?;
let parent_commits = match repo.head() {
Ok(head_ref) => {
let head = head_ref
.target()
.ok_or_else(|| anyhow::anyhow!("HEAD does not point to a valid commit"))?;
vec![repo.find_commit(head)?]
}
Err(_) => Vec::new(),
};

let parents: Vec<&git2::Commit> = parent_commits.iter().collect();
let commit_oid = repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)?;
Ok(commit_oid.to_string())
}

fn git_status(&self) -> Result<Vec<FileInfo>> {
let repo = self.get_repository()?;
let mut status_opts = StatusOptions::new();
status_opts
.include_untracked(true)
.recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut status_opts))?;
let mut file_infos = Vec::new();
for entry in statuses.iter() {
let status = Self::status_to_string(entry.status());
let path = entry.path().unwrap_or("unknown").to_string();
file_infos.push(FileInfo { status, path });
}
Ok(file_infos)
}

fn git_log(&self) -> Result<Vec<ChangedLog>> {
let repo = self.get_repository()?;
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
let mut changed_logs = Vec::new();
for commit_id in revwalk {
let commit = &repo.find_commit(commit_id?)?;
let user = commit.author().name().unwrap_or("unknown").to_string();
let time = commit.time();
let message = commit.message().unwrap_or("").to_string();
let tree = commit.tree()?;
let parent = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
let mut diff_opts = DiffOptions::new();
diff_opts.include_untracked(false);
diff_opts.include_ignored(false);
let diff =
repo.diff_tree_to_tree(parent.as_ref(), Some(&tree), Some(&mut diff_opts))?;
let mut paths = Vec::new();
for delta in diff.deltas() {
let status = Self::delta_status_to_string(delta.status());
let path = delta
.new_file()
.path()
.and_then(|p| p.to_str())
.unwrap_or("unknown")
.to_string();
paths.push(FileInfo { status, path });
}
changed_logs.push(ChangedLog {
user,
time,
message,
paths,
});
}
Ok(changed_logs)
}

async fn create_default_directories(&self) -> Result<()> {
let base_path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string());
let directories = ["solutions", "tests", "statements"];
for dir in directories {
let path = base_path.join(dir);
fs::create_dir_all(path)
.await
.with_context(|| format!("Failed to create directory: {}", dir))?;
}
Ok(())
}

fn delta_status_to_string(status: git2::Delta) -> String {
match status {
git2::Delta::Added => "ADDED".to_string(),
git2::Delta::Modified => "MODIFIED".to_string(),
git2::Delta::Deleted => "DELETED".to_string(),
git2::Delta::Renamed => "RENAMED".to_string(),
git2::Delta::Typechange => "TYPECHANGE".to_string(),
_ => "OTHER".to_string(),
}
}

fn status_to_string(status: git2::Status) -> String {
match status {
// Not yet added to index
git2::Status::WT_NEW => "ADDED".to_string(),
git2::Status::WT_MODIFIED => "MODIFIED".to_string(),
git2::Status::WT_DELETED => "DELETED".to_string(),
git2::Status::WT_RENAMED => "RENAMED".to_string(),
git2::Status::WT_TYPECHANGE => "TYPECHANGE".to_string(),

// Staged state
git2::Status::INDEX_NEW => "ADDED".to_string(),
git2::Status::INDEX_MODIFIED => "MODIFIED".to_string(),
git2::Status::INDEX_DELETED => "DELETED".to_string(),
git2::Status::INDEX_RENAMED => "RENAMED".to_string(),
git2::Status::INDEX_TYPECHANGE => "TYPECHANGE".to_string(),

_ => "OTHER".to_string(),
}
}

fn get_repository(&self) -> Result<Repository> {
let path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string());
if let Ok(repo) = Repository::open(&path) {
let mut config = repo.config()?;
config.set_str("user.name", "admin")?;
config.set_str("user.email", "[email protected]")?;
Ok(repo)
} else {
Err(anyhow::anyhow!(
"Failed to open git repository at {:?}",
path
))
}
}
}

#[derive(Debug, PartialEq, Eq)]
struct ChangedLog {
user: String,
time: Time,
message: String,
paths: Vec<FileInfo>,
}

#[derive(Debug, PartialEq, Eq)]
struct FileInfo {
status: String,
path: String,
}

#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::path::Path;
use tokio::fs;

#[rstest]
#[case(git2::Delta::Added, "ADDED")]
#[case(git2::Delta::Modified, "MODIFIED")]
#[case(git2::Delta::Deleted, "DELETED")]
#[case(git2::Delta::Renamed, "RENAMED")]
#[case(git2::Delta::Typechange, "TYPECHANGE")]
fn can_parse_delta_status_to_string(#[case] status: git2::Delta, #[case] expected: String) {
assert_eq!(GitManager::delta_status_to_string(status), expected);
}

#[rstest]
#[case(git2::Status::WT_NEW, "ADDED")]
#[case(git2::Status::WT_MODIFIED, "MODIFIED")]
#[case(git2::Status::WT_DELETED, "DELETED")]
#[case(git2::Status::WT_RENAMED, "RENAMED")]
#[case(git2::Status::WT_TYPECHANGE, "TYPECHANGE")]
#[case(git2::Status::INDEX_NEW, "ADDED")]
#[case(git2::Status::INDEX_MODIFIED, "MODIFIED")]
#[case(git2::Status::INDEX_DELETED, "DELETED")]
#[case(git2::Status::INDEX_RENAMED, "RENAMED")]
#[case(git2::Status::INDEX_TYPECHANGE, "TYPECHANGE")]
fn can_parse_status_to_string(#[case] status: git2::Status, #[case] expected: String) {
assert_eq!(GitManager::status_to_string(status), expected);
}

#[tokio::test]
async fn can_init_git_repository() -> Result<(), std::io::Error> {
let problem_id = 10;
let git_manager = GitManager::new(problem_id);
assert!(git_manager.git_init().is_ok());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}").as_str()).exists());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/.git").as_str()).exists());

fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
Ok(())
}

#[tokio::test]
async fn can_create_default_file() -> Result<(), std::io::Error> {
let problem_id = 12;
let git_manager = GitManager::new(problem_id);
Comment on lines +254 to +256
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 테스트 방식에는 이런 문제점이 있습니다.

  • 테스트가 종료시에 직접 remove_dir_all 수행
    만약 도중에 테스트가 실패하면 파일은 지워지지 않는다는 문제가 있습니다. 이 문제는 https://docs.rs/tempdir/latest/tempdir/ 이 crate를 사용한다면 해결할 수 있을 거 같습니다. (scope 벗어나면 drop 시에 디렉터리 삭제 수행)

  • 각 테스트마다 직접 problem_id를 부여하여 테스트를 진행
    이는 테스트가 동시에 실행되어도 각각에는 영향을 주지 않게 하기 위함이였는데 일일히 번호를 부여하는 방식이라 굉장히 비효율적입니다. 이렇게 구현한 이유는 아래 문제와 연관되어 있는데, production에서 사용하는 problem_id를 피하기 위함이였습니다.

  • production이랑 test 환경에서의 UPLOAD_DIR이 동일
    production에 test에서 사용하는 problem_id와 동일한 problem_id가 있다면 삭제될 위험이 있습니다.
    그래서 환경을 분리해야할 필요성을 느꼈는데, 이 작업을 해당 PR에 한번에 올라가면 굉장히 빵빵한 PR이 될 것 같아 일단 이렇게 구현하였습니다. 만약 환경이 분리된다면 각 테스트마다 직접 problem_id를 부여하여 테스트를 진행하는 방식도 그냥 랜덤으로 부여하여 해결할 수 있을 듯 합니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점은 다음 PR에서 개선하나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵! 환경 분리까지 진행하면 변경사항이 커질 것 같아 다음 PR에서 작업하려 합니다!

assert!(git_manager.create_default_directories().await.is_ok());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/solutions").as_str()).exists());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/tests").as_str()).exists());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/statements").as_str()).exists());

fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
Ok(())
}

#[tokio::test]
async fn can_create_problem() -> Result<(), std::io::Error> {
let problem_id = 13;
let git_manager = GitManager::new(problem_id);
assert!(git_manager.create_problem().await.is_ok());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}").as_str()).exists());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/.git").as_str()).exists());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/solutions").as_str()).exists());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/tests").as_str()).exists());
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/statements").as_str()).exists());

fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
Ok(())
}

#[tokio::test]
async fn can_get_git_status() -> Result<(), tokio::io::Error> {
let problem_id = 14;
let git_manager = GitManager::new(problem_id);

git_manager.git_init().unwrap();

fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?;
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;

let file_infos = git_manager.git_status().unwrap();
let expected = vec![
FileInfo {
status: "ADDED".to_string(),
path: "tests/1.in".to_string(),
},
FileInfo {
status: "ADDED".to_string(),
path: "tests/1.out".to_string(),
},
];
assert_eq!(file_infos, expected);

fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
Ok(())
}

#[tokio::test]
async fn can_git_add() -> Result<(), tokio::io::Error> {
let problem_id = 15;
let git_manager = GitManager::new(problem_id);

git_manager.git_init().unwrap();

let repo = git_manager.get_repository().unwrap();
fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?;
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;

assert!(git_manager.git_add_all().is_ok());

let statuses = repo.statuses(None).unwrap();

// 워킹 디렉토리에 존재하지 않아야 한다.
assert!(!statuses.iter().any(|e| e.status().is_wt_new()));
assert!(!statuses.iter().any(|e| e.status().is_wt_modified()));
assert!(!statuses.iter().any(|e| e.status().is_wt_deleted()));

// 스테이징 영역에 올라와야 한다.
assert!(statuses.iter().all(|e| e.status().is_index_new()));

fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
Ok(())
}

#[tokio::test]
async fn can_commit() -> Result<(), tokio::io::Error> {
let problem_id = 16;
let git_manager = GitManager::new(problem_id);
git_manager.git_init().unwrap();

fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?;
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;

let commit_message = "add test 1";

assert!(git_manager.git_commit(commit_message.to_string()).is_ok());

let repo = git_manager.get_repository().unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();

assert_eq!(commit.message(), Some(commit_message));

fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
Ok(())
}

#[tokio::test]
async fn can_get_log() -> Result<(), tokio::io::Error> {
let problem_id = 17;
let git_manager = GitManager::new(problem_id);
git_manager.git_init().unwrap();
git_manager.create_default_directories().await.unwrap();

fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;

git_manager
.git_commit("create default file".to_string())
.unwrap();

let log = git_manager.git_log().unwrap();

let expected_path = vec![
FileInfo {
status: "ADDED".to_string(),
path: "tests/1.in".to_string(),
},
FileInfo {
status: "ADDED".to_string(),
path: "tests/1.out".to_string(),
},
];
assert_eq!(log[0].paths, expected_path);

fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
Ok(())
}
}
1 change: 1 addition & 0 deletions src/file_manager/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod git;
mod handlers;
mod models;

Expand Down
Loading