# Day13 미션(소스 저장 오브젝트Ⓜ️)
56-B S054, S059
## 체크리스트
- [x] Git 개체에 대해 학습 및 정리하기
- [x] Git 명령어에 따른 동작 방식 이해하기
- [x] 사용자 입력에 대해서 검증하는 객체 및 로직 구현하기
- [x] init 명령어 동작 구현하기
- [x] add 명령어 동작 구현하기
- [x] status 명령어 동작 구현하기
- [x] commit 명령어 동작 구현하기
- [ ] log 명령어 동작 구현하기
- [ ] restore 명령어 동작 구현하기
## 문제 해결 과정
### 전개 과정
1. Git의 작동 과정 및 원리와 Git 개체에 대한 이해가 필요했기 때문에, 각자 간단한 학습 과정을 거쳐서 짝 프로그래밍 시작
2. 요구사항 분석 및 체크리스트 작성
3. 드라이버와 내비게이터를 번갈아 가며 프로그램 시작부터 명령어에 대한 처리들을 차례대로 구현하여 미션 수행
## 구현 내용
### 타입 설명
#### HashContent
```swift
/// hash: sha를 통해 얻은 해쉬값
/// content: 해쉬값을 얻을 때 사용된 변경된 파일의 내용
struct HashContent: Hashable {
let hash: String
let content: String
}
```
#### Commend
```swift
/// 입력 가능한 명령어 정의
enum Commend: String {
case `init`
case add
case status
case commit
case log
case restore
}
```
#### InputError
```swift
/// 잘못된 입력값이 들어왔을 때 방출할 Error 타입
enum MitError: LocalizedError {
case nilInput
case invalidCommend
case nonChanged
var errorDescription: String? {
switch self {
case .nilInput:
"입력값이 잘못되었습니다"
case .invalidCommend:
"유효하지 않는 명령어입니다"
case .nonChanged:
"변경사항이 없습니다."
}
}
}
```
### 프로그램 실행 함수 run()
```swift
var hashMap = [HashContent: [String]]()
func run() {
while let input = readLine() {
if input == ":wq" { break }
do {
guard checkMitCommend(input: input) else { continue }
try splitCommend(input: input)
} catch {
print(error.localizedDescription)
}
}
}
func checkMitCommend(input: String) -> Bool {
return input.hasPrefix("mit ")
}
func splitCommend(input: String) throws {
let splittedInput = input.split(separator: " ").map { String($0) }
guard let commend = Commend(rawValue: splittedInput[1]), splittedInput.count >= 3 else { throw InputError.invalidCommend }
switch commend {
case .`init`:
try mitInit(directory: splittedInput[2])
case .add:
try mitAdd(directory: splittedInput[2])
case .status:
mitStatus(directory: splittedInput[2])
case .commit: try mitCommit(directory: splittedInput[2])
case .log: break
case .restore: break
}
}
```
- while문을 통해서 사용자로부터 명령어를 반복적으로 입력을 받도록 한다. readLine()은 옵셔널 문자열이기 때문에, while let을 통해서 바인딩을 함께 해주었다.
- 종료에 대한 명령어도 필요하다고 판단하여 `:wq`를 입력한 경우, 프로그램을 종료하도록 한다.
- 명령어는 mit로 시작하는 문자열이기 때문에, `hasPrefix` 함수를 이용하여 입력값이 mit로 시작하는지를 확인한다. 또한 `“ “`을 기준으로 입력값을 쪼개고, mit 다음에 오는 문자열이 열거형으로 정의한 명령어 셋에 포함되는지를 열거형의 원시값을 이용해 확인한다.
- 각 명령어마다 해당하는 함수들을 호출하여 명령어 별 동작을 수행한다.
### init 명령어 동작 구현
```swift
func mitInit(directory: String) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(atPath: "\(fileManager.currentDirectoryPath)/\(directory)/.mit/objects/", withIntermediateDirectories: true)
try fileManager.createDirectory(atPath: "\(fileManager.currentDirectoryPath)/\(directory)/.mit/index/", withIntermediateDirectories: true)
}
```
- FileManager를 통해서 현재 디렉토리 경로에 입력된 디렉토리명에 해당하는 디렉토리와 `./mit` 디렉토리 구조`.mit/objects/`와 `.mit/index/`를 생성한다.
### add 명령어 동작 구현
```swift
func mitAdd(directory: String) throws {
/// 디렉토리에서 전체 파일 목록을 탐색하고, 각 파일 내용에 대한 sha256 해시 값을 비교한다.
/// commit이 없거나 직전 commit 이후 해시값이 달라진 파일 목록을 저장한다
let desktopPath = "\(FileManager.default.currentDirectoryPath)/\(directory)"
let files = try FileManager.default
.subpathsOfDirectory(atPath: desktopPath)
.filter {
return !$0.hasPrefix(".")
}
.filter { content in
let splitContent = content.split(separator: ".")
if splitContent.count == 2 {
return true
} else {
return false
}
}
files.forEach { file in
guard let content = try? String(contentsOfFile: "\(FileManager.default.currentDirectoryPath)/\(directory)/\(file)") else { return }
guard let data = content.data(using: .utf8) else { return }
let sha256 = SHA256.hash(data: data)
let shaData = sha256.compactMap{String(format: "%02x", $0)}.joined()
let hashContent = HashContent(hash: shaData, content: content)
hashMap[hashContent] = (hashMap[hashContent] ?? []) + [file]
}
}
```
- 현재 경로에 입력된 디렉토리명을 더하여 경로를 만든다.
- subpathsOfDirectory를 통해서 해당 경로에 존재하는 모든 디렉토리 및 파일들을 읽고, 파일인 것만 추려낸다.
- 파일들을 forEach문으로 순회하면서 파일의 내용에 대해 sha256 해시 값을 생성하고 hashMap의 키로 하여 파일의 내용들을 저장한다.
### status 명령어 동작 구현
`add`를 통해 hashMap에 저장된 변경 사항을 통해 변경된 파일의 경로와 파일명 출력
```swift
func mitStatus(directory: String) throws {
if hashMap.isEmpty { throw MitError.nonChanged }
for files in hashMap.values {
files.forEach { file in
let splitFile = file.split(separator: "/").map { String($0) }
print("\n경로: \(FileManager.default.currentDirectoryPath)/\(directory)/\(file)") // 경로
print("파일명: \(splitFile[splitFile.count - 1])")
}
}
}
```
### commit 명령어 동작 구현
#### commit 동작 순서
1. blobObject 생성
1-1. `add` 명령어로 `hashMap` 딕셔너리에 추가된 데이터로 blob object 생성
1-2. tree object에 저장될 blob 정보 저장
2. treeObject 생성
2-1. blob 정보를 통해 tree object 생성
2-2. tree Object의 해쉬값을 commit Object의 저장
3. commitObject 생성
3-1. commits에서 상단을 읽어온다 => commits 없으면, 4로 부터 수행
3-2. 읽어온 commit 해쉬값을 통해 commit 오프젝트를 찾는다.
3-3. commit 오브젝트에서 현재 tree의 해쉬값을 가져온다.
3-4. 가져온 tree 해쉬값을 이전에, 2-2에서 전달받은 값을 현재에 저장
3-5. 새로운 commit 해쉬값 생성
3-6. commits 상단에 업데이트 => 없으면 생성
4. hashMap 초기화
```swift
func mitCommit(directory: String) throws {
let path = "\(FileManager.default.currentDirectoryPath)/\(directory)/.mit"
var treeContent = ""
for (key, value) in hashMap {
/// blob 만들기
let blobDirectoryName = key.hash.prefix(8)
let blobObject = key.hash.map { String($0) }[8...].joined()
let content = key.content
let data = NSData(data: Data(content.utf8))
let compressedData = try data.compressed(using: .zlib) as Data
try FileManager.default.createDirectory(atPath: "\(path)/objects/\(blobDirectoryName)", withIntermediateDirectories: false)
FileManager.default.createFile(atPath: "\(path)/objects/\(blobDirectoryName)/\(blobObject).zlib", contents: compressedData)
let blobHash = key.hash
let fileLength = compressedData.count
for fileName in value {
treeContent += "\(blobHash), \(fileLength), \(fileName)\n"
}
}
/// tree object 생성
guard let treeData = treeContent.data(using: String.Encoding.utf8) else { return }
let sha256 = SHA256.hash(data: treeData)
let treeHash = sha256.compactMap{String(format: "%02x", $0)}.joined()
let treeObject = treeHash.map { String($0) }[8...].joined()
FileManager.default.createFile(atPath: "\(path)/objects/\(treeObject).txt", contents: treeData)
/// commit Object 생성 과정
///1. commits에서 상단을 읽어온다 => commits 없으면, 4로 부터 수행
///2. 읽어온 commit 해쉬값을 통해 commit 오프젝트를 찾는다.
///3. commit 오브젝트에서 현재 tree의 해쉬값을 가져온다.
///4. 가져온 tree 해쉬값을 이전에, `treeHash` 값을 현재에 저장
///5. 새로운 commit 해쉬값 생성
///6. commits 상단에 업데이트
// 1
var beforeTreeObject = ""
if FileManager.default.fileExists(atPath: "\(path)/index/commits.txt") {
//2
let commitsContent = try String(contentsOfFile: "\(path)/index/commits.txt")
let commitObject = commitsContent.split(separator: "\n").map { String($0) }[0]
//3
if let treeObjects = try? String(contentsOfFile: "\(path)/objects/\(commitObject).txt") {
beforeTreeObject = treeObjects
.split(separator: ", ")
.map { String($0) }.last ?? ""
}
}
//4 "이전, 현재"
let commitContent = "\(beforeTreeObject), \(treeObject)"
guard let commitData = commitContent.data(using: .utf8) else { return }
let commitHash = SHA256.hash(data: commitData).compactMap{String(format: "%02x", $0)}.joined()
let commitObject = commitHash.map { String($0) }[8...].joined()
//5
FileManager.default.createFile(atPath: "\(path)/objects/\(commitObject).txt", contents: commitData)
//6
//6-1 이미 작성된 commits를 읽어와서 상단에 `commitName` 추가
if let commitsContent = try? String(contentsOfFile: "\(path)/index/commits.txt") {
let newCommitsContent = "\(commitObject)\n\(commitsContent)"
try newCommitsContent.write(toFile: "\(path)/index/commits.txt", atomically: true, encoding: .utf8)
} else {
let data = "\(commitObject)\n".data(using: .utf8)
FileManager.default.createFile(atPath: "\(path)/index/commits.txt", contents: data)
}
hashMap = [:]
}
```
## 학습 메모
- [Git 개체](https://git-scm.com/book/ko/v2/Git의-내부-Git-개체)
Git 에서 변경사항을 추적하기 위해 사용한다.
#### (1) Blob 파일
- Binary Large Object.
- 각 버전의 파일이 blob 파일이다. 소스 코드, 이미지 등 다양한 파일의 데이터를 저장한다. 파일의 메타 데이터를 저장하지 않고 데이터 자체만을 저장한다. (파일명은 메타 데이터로, 저장되지 않음)
- 따라서 동일한 소스 코드를 가진 파일이 여러 개 있더라도 하나의 blob 파일만 생성된다.
#### (2) Tree 파일
- Blob에서 실제 파일의 데이터들을 저장하는 것과 다르게, Tree에는 파일 식별자, 파일 데이터의 해시값, 파일명이 저장된다.
- 폴더가 파일과 폴더로 구성되는 것처럼, tree는 blob과 또 다른 tree로 구성된다. 즉, blob 혹은 다른 tree를 참조한다.
- 파일 식별자는 100644(읽기 파일(blob)), 100755(실행 파일(blob)), 040000(디렉터리(tree)) 세 가지로만 구성된다.
#### (3) Commit 파일
- 각각의 커밋별로 하나의 커밋 파일로 저장된다. git으로 관리되는 가장 바깥 tree의 해시값, author, commiter, 커밋 메시지의 정보가 저장된다.
- parent에는 직전 커밋의 해시값이 저장되고, Linked List의 형태로 커밋들이 구성된다.
# day14 미션(소스 저장 오브젝트Ⓜ️) 개선하기
## 개선 포인트
- [ ] 기존 tree에 변경사항만 저장하는 것이 아닌 커밋 순간의 파일 정보를 저장
- 선정 이유: tree의 변경사항만 저장하는 경우 `add` 또는 `restore` 명령어를 수행하기 위해 커밋 순서로 모든 tree의 정보를 읽어와야 함으로 효율성이 떨어질 수 있다고 판단
- [ ] day13에서 구현하지 못한 요구사항 구현
- 선정 이유: 요구사항을 구현하며, 학습한 git 동작 원리를 구체화 할 수 있다고 판단
- [ ] tree를 디렉토리 계층 별로 생성
- 선정 이유: git 방식에서 `treeObject`가 디렉토리 별 트리 구조로 생성되기 때문