Skip to content

Commit 29f1de7

Browse files
committed
feat: implement version control and change log for problem files using git2
1 parent ce1547a commit 29f1de7

File tree

3 files changed

+378
-1
lines changed

3 files changed

+378
-1
lines changed

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ name = "coduck-backend"
1414
anyhow = "1.0"
1515
axum = { version = "0.8.4", features = ["json", "multipart"] }
1616
chrono = { version = "0.4.38", features = ["serde"] }
17-
reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] }
17+
git2 = "0.20.2"
1818
serde = { version = "1.0.219", features = ["derive"] }
1919
serde_json = "1.0.133"
2020
tokio = { version = "1.45.1", features = ["full"] }
2121
uuid = { version = "1.17.0", features = ["v4"] }
2222

2323
[dev-dependencies]
24+
reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] }
2425
rstest = "0.25.0"

src/file_manager/git.rs

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
#![allow(dead_code)]
2+
3+
use anyhow::{Context, Result};
4+
use git2::{DiffOptions, IndexAddOption, Repository, StatusOptions, Time};
5+
use std::path::PathBuf;
6+
use tokio::fs;
7+
8+
const UPLOAD_DIR: &str = "uploads";
9+
10+
#[derive(Debug)]
11+
struct GitManager {
12+
problem_id: u32,
13+
}
14+
15+
impl GitManager {
16+
fn new(problem_id: u32) -> Self {
17+
Self { problem_id }
18+
}
19+
20+
fn git_init(&self) -> Result<()> {
21+
let path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string());
22+
Repository::init(&path)
23+
.map(|_| ())
24+
.with_context(|| format!("Failed to init git repo at {:?}", path))
25+
}
26+
27+
async fn create_problem(&self) -> Result<()> {
28+
self.git_init()?;
29+
self.create_default_directories().await?;
30+
self.git_add_all()?;
31+
Ok(())
32+
}
33+
34+
fn git_add_all(&self) -> Result<()> {
35+
let repo = self.get_repository()?;
36+
let mut idx = repo.index()?;
37+
idx.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?;
38+
idx.write()?;
39+
Ok(())
40+
}
41+
42+
fn git_commit(&self, message: String) -> Result<String> {
43+
self.git_add_all()?;
44+
let repo = self.get_repository()?;
45+
let mut idx = repo.index()?;
46+
let tree_id = idx.write_tree()?;
47+
let tree = repo.find_tree(tree_id)?;
48+
let sig = repo.signature()?;
49+
let parent_commits = match repo.head() {
50+
Ok(head_ref) => {
51+
let head = head_ref
52+
.target()
53+
.ok_or_else(|| anyhow::anyhow!("HEAD refers to non-HEAD"))?;
54+
vec![repo.find_commit(head)?]
55+
}
56+
Err(_) => Vec::new(),
57+
};
58+
59+
let parents: Vec<&git2::Commit> = parent_commits.iter().collect();
60+
let commit_oid = repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)?;
61+
Ok(commit_oid.to_string())
62+
}
63+
64+
fn git_status(&self) -> Result<Vec<FileInfo>> {
65+
let repo = self.get_repository()?;
66+
let mut status_opts = StatusOptions::new();
67+
status_opts
68+
.include_untracked(true)
69+
.recurse_untracked_dirs(true);
70+
let statuses = repo.statuses(Some(&mut status_opts))?;
71+
let mut file_infos = Vec::new();
72+
for entry in statuses.iter() {
73+
let status = Self::status_to_string(entry.status());
74+
let path = entry.path().unwrap_or("unknown").to_string();
75+
file_infos.push(FileInfo { status, path });
76+
}
77+
Ok(file_infos)
78+
}
79+
80+
fn git_log(&self) -> Result<Vec<ChangedLog>> {
81+
let repo = self.get_repository()?;
82+
let mut revwalk = repo.revwalk()?;
83+
revwalk.push_head()?;
84+
let mut changed_logs = Vec::new();
85+
for commit_id in revwalk {
86+
let commit = &repo.find_commit(commit_id?)?;
87+
let user = commit.author().name().unwrap_or("unknown").to_string();
88+
let time = commit.time();
89+
let message = commit.message().unwrap_or("").to_string();
90+
let tree = commit.tree()?;
91+
let parent = if commit.parent_count() > 0 {
92+
Some(commit.parent(0)?.tree()?)
93+
} else {
94+
None
95+
};
96+
let mut diff_opts = DiffOptions::new();
97+
diff_opts.include_untracked(false);
98+
diff_opts.include_ignored(false);
99+
let diff =
100+
repo.diff_tree_to_tree(parent.as_ref(), Some(&tree), Some(&mut diff_opts))?;
101+
let mut paths = Vec::new();
102+
for delta in diff.deltas() {
103+
let status = Self::delta_status_to_string(delta.status());
104+
let path = delta
105+
.new_file()
106+
.path()
107+
.and_then(|p| p.to_str())
108+
.unwrap_or("unknown")
109+
.to_string();
110+
paths.push(FileInfo { status, path });
111+
}
112+
changed_logs.push(ChangedLog {
113+
user,
114+
time,
115+
message,
116+
paths,
117+
});
118+
}
119+
Ok(changed_logs)
120+
}
121+
122+
async fn create_default_directories(&self) -> Result<()> {
123+
let base_path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string());
124+
let directories = [
125+
"solutions/accepted",
126+
"solutions/rejected",
127+
"tests",
128+
"statements",
129+
];
130+
for dir in directories {
131+
let path = base_path.join(dir);
132+
fs::create_dir_all(path)
133+
.await
134+
.with_context(|| format!("Failed to create directory: {}", dir))?;
135+
}
136+
Ok(())
137+
}
138+
139+
fn delta_status_to_string(status: git2::Delta) -> String {
140+
match status {
141+
git2::Delta::Added => "ADDED".to_string(),
142+
git2::Delta::Modified => "MODIFIED".to_string(),
143+
git2::Delta::Deleted => "DELETED".to_string(),
144+
git2::Delta::Renamed => "RENAMED".to_string(),
145+
git2::Delta::Typechange => "TYPECHANGE".to_string(),
146+
_ => "OTHER".to_string(),
147+
}
148+
}
149+
150+
fn status_to_string(status: git2::Status) -> String {
151+
match status {
152+
// 아직 add 되지 않은 상태
153+
git2::Status::WT_NEW => "ADDED".to_string(),
154+
git2::Status::WT_MODIFIED => "MODIFIED".to_string(),
155+
git2::Status::WT_DELETED => "DELETED".to_string(),
156+
git2::Status::WT_RENAMED => "RENAMED".to_string(),
157+
git2::Status::WT_TYPECHANGE => "TYPECHANGE".to_string(),
158+
159+
// stage 된 상태
160+
git2::Status::INDEX_NEW => "ADDED".to_string(),
161+
git2::Status::INDEX_MODIFIED => "MODIFIED".to_string(),
162+
git2::Status::INDEX_DELETED => "DELETED".to_string(),
163+
git2::Status::INDEX_RENAMED => "RENAMED".to_string(),
164+
git2::Status::INDEX_TYPECHANGE => "TYPECHANGE".to_string(),
165+
166+
_ => "OTHER".to_string(),
167+
}
168+
}
169+
170+
fn get_repository(&self) -> Result<Repository> {
171+
let path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string());
172+
Repository::open(path).context("Failed to open git repository")
173+
}
174+
}
175+
176+
#[derive(Debug, PartialEq, Eq)]
177+
struct ChangedLog {
178+
user: String,
179+
time: Time,
180+
message: String,
181+
paths: Vec<FileInfo>,
182+
}
183+
184+
#[derive(Debug, PartialEq, Eq)]
185+
struct FileInfo {
186+
status: String,
187+
path: String,
188+
}
189+
190+
#[cfg(test)]
191+
mod tests {
192+
use super::*;
193+
use rstest::rstest;
194+
use std::path::Path;
195+
use tokio::fs;
196+
197+
#[rstest]
198+
#[case(git2::Delta::Added, "ADDED")]
199+
#[case(git2::Delta::Modified, "MODIFIED")]
200+
#[case(git2::Delta::Deleted, "DELETED")]
201+
#[case(git2::Delta::Renamed, "RENAMED")]
202+
#[case(git2::Delta::Typechange, "TYPECHANGE")]
203+
fn can_parse_delta_status_to_string(#[case] status: git2::Delta, #[case] expected: String) {
204+
assert_eq!(GitManager::delta_status_to_string(status), expected);
205+
}
206+
207+
#[rstest]
208+
#[case(git2::Status::WT_NEW, "ADDED")]
209+
#[case(git2::Status::WT_MODIFIED, "MODIFIED")]
210+
#[case(git2::Status::WT_DELETED, "DELETED")]
211+
#[case(git2::Status::WT_RENAMED, "RENAMED")]
212+
#[case(git2::Status::WT_TYPECHANGE, "TYPECHANGE")]
213+
#[case(git2::Status::INDEX_NEW, "ADDED")]
214+
#[case(git2::Status::INDEX_MODIFIED, "MODIFIED")]
215+
#[case(git2::Status::INDEX_DELETED, "DELETED")]
216+
#[case(git2::Status::INDEX_RENAMED, "RENAMED")]
217+
#[case(git2::Status::INDEX_TYPECHANGE, "TYPECHANGE")]
218+
fn can_parse_status_to_string(#[case] status: git2::Status, #[case] expected: String) {
219+
assert_eq!(GitManager::status_to_string(status), expected);
220+
}
221+
222+
#[tokio::test]
223+
async fn can_init_git_repository() -> Result<(), std::io::Error> {
224+
let problem_id = 10;
225+
let git_manager = GitManager::new(problem_id);
226+
assert!(git_manager.git_init().is_ok());
227+
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}").as_str()).exists());
228+
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/.git").as_str()).exists());
229+
230+
fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
231+
Ok(())
232+
}
233+
234+
#[tokio::test]
235+
async fn can_create_default_file() -> Result<(), std::io::Error> {
236+
let problem_id = 12;
237+
let git_manager = GitManager::new(problem_id);
238+
assert!(git_manager.create_default_directories().await.is_ok());
239+
240+
fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
241+
Ok(())
242+
}
243+
244+
#[tokio::test]
245+
async fn can_create_problem() -> Result<(), std::io::Error> {
246+
let problem_id = 13;
247+
let git_manager = GitManager::new(problem_id);
248+
assert!(git_manager.create_problem().await.is_ok());
249+
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}").as_str()).exists());
250+
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/.git").as_str()).exists());
251+
assert!(
252+
Path::new(format!("{UPLOAD_DIR}/{problem_id}/solutions/accepted").as_str()).exists()
253+
);
254+
assert!(
255+
Path::new(format!("{UPLOAD_DIR}/{problem_id}/solutions/rejected").as_str()).exists()
256+
);
257+
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/tests").as_str()).exists());
258+
assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/statements").as_str()).exists());
259+
260+
fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
261+
Ok(())
262+
}
263+
264+
#[tokio::test]
265+
async fn can_get_git_status() -> Result<(), tokio::io::Error> {
266+
let problem_id = 14;
267+
let git_manager = GitManager::new(problem_id);
268+
269+
git_manager.git_init().unwrap();
270+
271+
fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?;
272+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
273+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;
274+
275+
let file_infos = git_manager.git_status().unwrap();
276+
let expected = vec![
277+
FileInfo {
278+
status: "ADDED".to_string(),
279+
path: "tests/1.in".to_string(),
280+
},
281+
FileInfo {
282+
status: "ADDED".to_string(),
283+
path: "tests/1.out".to_string(),
284+
},
285+
];
286+
assert_eq!(file_infos, expected);
287+
288+
fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
289+
Ok(())
290+
}
291+
292+
#[tokio::test]
293+
async fn can_git_add() -> Result<(), tokio::io::Error> {
294+
let problem_id = 15;
295+
let git_manager = GitManager::new(problem_id);
296+
297+
git_manager.git_init().unwrap();
298+
299+
let repo = git_manager.get_repository().unwrap();
300+
fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?;
301+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
302+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;
303+
304+
assert!(git_manager.git_add_all().is_ok());
305+
306+
let statuses = repo.statuses(None).unwrap();
307+
308+
// 워킹 디렉토리에 존재하지 않아야 한다.
309+
assert!(!statuses.iter().any(|e| e.status().is_wt_new()));
310+
assert!(!statuses.iter().any(|e| e.status().is_wt_modified()));
311+
assert!(!statuses.iter().any(|e| e.status().is_wt_deleted()));
312+
313+
// 스테이징 영역에 올라와야 한다.
314+
assert!(statuses.iter().all(|e| e.status().is_index_new()));
315+
316+
fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
317+
Ok(())
318+
}
319+
320+
#[tokio::test]
321+
async fn can_commit() -> Result<(), tokio::io::Error> {
322+
let problem_id = 16;
323+
let git_manager = GitManager::new(problem_id);
324+
git_manager.git_init().unwrap();
325+
326+
fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?;
327+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
328+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;
329+
330+
let commit_message = "add test 1";
331+
332+
assert!(git_manager.git_commit(commit_message.to_string()).is_ok());
333+
334+
let repo = git_manager.get_repository().unwrap();
335+
let head = repo.head().unwrap();
336+
let commit = head.peel_to_commit().unwrap();
337+
338+
assert_eq!(commit.message(), Some(commit_message));
339+
340+
fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
341+
Ok(())
342+
}
343+
344+
#[tokio::test]
345+
async fn can_get_log() -> Result<(), tokio::io::Error> {
346+
let problem_id = 17;
347+
let git_manager = GitManager::new(problem_id);
348+
git_manager.git_init().unwrap();
349+
git_manager.create_default_directories().await.unwrap();
350+
351+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?;
352+
fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?;
353+
354+
git_manager
355+
.git_commit("create default file".to_string())
356+
.unwrap();
357+
358+
let log = git_manager.git_log().unwrap();
359+
360+
let expected_path = vec![
361+
FileInfo {
362+
status: "ADDED".to_string(),
363+
path: "tests/1.in".to_string(),
364+
},
365+
FileInfo {
366+
status: "ADDED".to_string(),
367+
path: "tests/1.out".to_string(),
368+
},
369+
];
370+
assert_eq!(log[0].paths, expected_path);
371+
372+
fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?;
373+
Ok(())
374+
}
375+
}

src/file_manager/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod handlers;
22
mod models;
3+
mod git;
34

45
pub(crate) use handlers::*;
56
pub use models::*;

0 commit comments

Comments
 (0)