기능개선
This commit is contained in:
280
cmd/main.go
280
cmd/main.go
@@ -1,68 +1,260 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/backup"
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/backup"
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/constants"
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/logger"
|
||||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/path"
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/path"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
type Options struct {
|
||||||
if len(os.Args) < 3 {
|
Src string
|
||||||
fmt.Println("사용법: backup <원본경로> <백업경로> [옵션]")
|
Dst string
|
||||||
os.Exit(1)
|
GroupBy string
|
||||||
|
CompareMode string
|
||||||
|
Incremental bool
|
||||||
|
DryRun bool
|
||||||
|
Verbose bool
|
||||||
|
Force bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationError struct {
|
||||||
|
Option string
|
||||||
|
Value string
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("%s 옵션 오류: %s (%s)", e.Option, e.Msg, e.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showHelp() {
|
||||||
|
helpText := `사용법: backup <원본경로> <백업경로> [옵션]
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
-g, --group-by 백업 폴더 구조 기준 (기본값: day)
|
||||||
|
가능한 값: year, mon, day, hour, min, sec
|
||||||
|
|
||||||
|
-i, --incremental 증분 백업 사용
|
||||||
|
기존 백업과 비교하여 변경된 파일만 백업
|
||||||
|
|
||||||
|
-c, --compare 파일 비교 방식 선택 (기본값: time)
|
||||||
|
- time: 파일 수정 시간으로 비교
|
||||||
|
- hash: 파일 내용의 해시값으로 비교
|
||||||
|
|
||||||
|
-d, --dry-run 하지 않고 어떤 파일이 복사되는지 출력
|
||||||
|
실제 파일 시스템을 변경하지 않음
|
||||||
|
|
||||||
|
-v, --verbose 복사 로그 자세히 출력
|
||||||
|
진행 상황과 세부 정보를 표시
|
||||||
|
|
||||||
|
-f, --force 속성 무시하고 무조건 덮어쓰기
|
||||||
|
기존 파일 존재 시 강제로 덮어씀
|
||||||
|
|
||||||
|
예시:
|
||||||
|
backup /source /backup --group-by day --compare hash
|
||||||
|
backup /home/user/docs /backup/docs -i -v
|
||||||
|
backup /data /backup -d --force
|
||||||
|
|
||||||
|
자세한 정보: https://github.com/yourusername/backup`
|
||||||
|
|
||||||
|
fmt.Println(helpText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOptions(opts Options) error {
|
||||||
|
// 필수 경로 검증
|
||||||
|
if opts.Src == "" {
|
||||||
|
return &ValidationError{Option: "src", Value: opts.Src, Msg: "원본 경로가 지정되지 않았습니다"}
|
||||||
|
}
|
||||||
|
if opts.Dst == "" {
|
||||||
|
return &ValidationError{Option: "dst", Value: opts.Dst, Msg: "백업 경로가 지정되지 않았습니다"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 경로 존재 확인
|
||||||
|
if _, err := os.Stat(opts.Src); os.IsNotExist(err) {
|
||||||
|
return &ValidationError{Option: "src", Value: opts.Src, Msg: "원본 경로가 존재하지 않습니다"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// group-by 옵션 검증
|
||||||
|
validGroupBy := map[string]bool{
|
||||||
|
"": true,
|
||||||
|
constants.GroupByYear: true,
|
||||||
|
constants.GroupByMonth: true,
|
||||||
|
constants.GroupByDay: true,
|
||||||
|
constants.GroupByHour: true,
|
||||||
|
constants.GroupByMin: true,
|
||||||
|
constants.GroupBySec: true,
|
||||||
|
}
|
||||||
|
if !validGroupBy[opts.GroupBy] {
|
||||||
|
return &ValidationError{
|
||||||
|
Option: "group-by",
|
||||||
|
Value: opts.GroupBy,
|
||||||
|
Msg: "지원하지 않는 그룹화 옵션입니다. 가능한 값: year, mon, day, hour, min, sec",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare 옵션 검증
|
||||||
|
validCompare := map[string]bool{
|
||||||
|
constants.CompareTime: true,
|
||||||
|
constants.CompareHash: true,
|
||||||
|
}
|
||||||
|
if !validCompare[opts.CompareMode] {
|
||||||
|
return &ValidationError{
|
||||||
|
Option: "compare",
|
||||||
|
Value: opts.CompareMode,
|
||||||
|
Msg: "지원하지 않는 비교 모드입니다. 가능한 값: time, hash",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 증분 백업 + force 모드 조합 경고
|
||||||
|
if opts.Incremental && opts.Force {
|
||||||
|
fmt.Println("경고: force 모드와 증분 백업을 함께 사용하면 증분 백업의 이점이 사라질 수 있습니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() (Options, error) {
|
||||||
|
if len(os.Args) < 2 || (len(os.Args) == 2 && (os.Args[1] == "-h" || os.Args[1] == "--help")) {
|
||||||
|
showHelp()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
return Options{}, fmt.Errorf("원본 경로와 백업 경로를 모두 지정해야 합니다")
|
||||||
}
|
}
|
||||||
src := os.Args[1]
|
|
||||||
dst := os.Args[2]
|
|
||||||
|
|
||||||
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
||||||
groupBy := fs.String("group-by", "", "백업 폴더 구조 기준: year, mon, day, hour, min, sec")
|
fs.Usage = showHelp
|
||||||
snapshot := fs.String("snapshot", "", "스냅샷 기준: y, ym, ymd")
|
|
||||||
dryRun := fs.Bool("dry-run", false, "복사하지 않고 어떤 파일이 복사되는지 출력")
|
opts := Options{
|
||||||
verbose := fs.Bool("verbose", false, "복사 로그 자세히 출력")
|
Src: os.Args[1],
|
||||||
force := fs.Bool("force", false, "속성 무시하고 무조건 덮어쓰기")
|
Dst: os.Args[2],
|
||||||
_ = fs.Parse(os.Args[3:])
|
GroupBy: constants.DefaultGroupBy,
|
||||||
|
CompareMode: constants.DefaultCompare,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플래그 정의
|
||||||
|
fs.StringVar(&opts.GroupBy, "group-by", constants.DefaultGroupBy, "")
|
||||||
|
fs.StringVar(&opts.GroupBy, "g", constants.DefaultGroupBy, "")
|
||||||
|
fs.StringVar(&opts.CompareMode, "compare", constants.DefaultCompare, "")
|
||||||
|
fs.StringVar(&opts.CompareMode, "c", constants.DefaultCompare, "")
|
||||||
|
fs.BoolVar(&opts.Incremental, "incremental", false, "")
|
||||||
|
fs.BoolVar(&opts.Incremental, "i", false, "")
|
||||||
|
fs.BoolVar(&opts.DryRun, "dry-run", false, "")
|
||||||
|
fs.BoolVar(&opts.DryRun, "d", false, "")
|
||||||
|
fs.BoolVar(&opts.Verbose, "verbose", false, "")
|
||||||
|
fs.BoolVar(&opts.Verbose, "v", false, "")
|
||||||
|
fs.BoolVar(&opts.Force, "force", false, "")
|
||||||
|
fs.BoolVar(&opts.Force, "f", false, "")
|
||||||
|
|
||||||
|
if err := fs.Parse(os.Args[3:]); err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOptions(opts Options, backupPath string) {
|
||||||
|
fmt.Println("\n실행 설정:")
|
||||||
|
fmt.Println("----------------------------------------")
|
||||||
|
fmt.Printf("원본 경로: %s\n", opts.Src)
|
||||||
|
fmt.Printf("최종 백업 경로: %s\n", backupPath)
|
||||||
|
fmt.Printf("그룹화 방식: %s\n", opts.GroupBy)
|
||||||
|
fmt.Printf("비교 모드: %s\n", opts.CompareMode)
|
||||||
|
fmt.Printf("증분 백업: %v\n", opts.Incremental)
|
||||||
|
fmt.Printf("드라이런 모드: %v\n", opts.DryRun)
|
||||||
|
fmt.Printf("상세 로그: %v\n", opts.Verbose)
|
||||||
|
fmt.Printf("강제 덮어쓰기: %v\n", opts.Force)
|
||||||
|
fmt.Println("----------------------------------------\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
opts, err := parseFlags()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("오류:", err)
|
||||||
|
showHelp()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateOptions(opts); err != nil {
|
||||||
|
fmt.Println("오류:", err)
|
||||||
|
fmt.Println("\n사용법을 확인하려면 --help를 사용하세요")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컨텍스트 설정
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 시그널 처리
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigCh
|
||||||
|
fmt.Println("\n백업 취소 요청됨...")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
backupPath := dst
|
backupPath := opts.Dst
|
||||||
|
if opts.GroupBy != "" {
|
||||||
if *groupBy != "" {
|
backupPath = path.GetGroupByFolder(opts.Dst, opts.GroupBy, now)
|
||||||
backupPath = path.GetGroupByFolder(dst, *groupBy, now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if *snapshot != "" {
|
log := logger.New(opts.Verbose)
|
||||||
var snapshotFolder string
|
|
||||||
switch *snapshot {
|
// 진행 상황 콜백 함수
|
||||||
case "y":
|
progressCallback := func(p backup.Progress) {
|
||||||
snapshotFolder = fmt.Sprintf("%d", now.Year())
|
if opts.Verbose {
|
||||||
case "ym":
|
percentComplete := 0.0
|
||||||
snapshotFolder = fmt.Sprintf("%d%02d", now.Year(), int(now.Month()))
|
if p.TotalFiles > 0 {
|
||||||
case "ymd":
|
percentComplete = float64(p.ProcessedFiles) / float64(p.TotalFiles) * 100
|
||||||
snapshotFolder = fmt.Sprintf("%d%02d%02d", now.Year(), int(now.Month()), now.Day())
|
}
|
||||||
default:
|
fmt.Printf("\r진행률: %.1f%% (%d/%d) - 현재: %s\n",
|
||||||
fmt.Printf("오류: 지원하지 않는 snapshot 옵션입니다: %s\n", *snapshot)
|
percentComplete, p.ProcessedFiles, p.TotalFiles, p.CurrentFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := backup.CompareTime
|
||||||
|
if opts.CompareMode == constants.CompareHash {
|
||||||
|
mode = backup.CompareHash
|
||||||
|
}
|
||||||
|
|
||||||
|
backupOpts := backup.BackupOptions{
|
||||||
|
DryRun: opts.DryRun,
|
||||||
|
Verbose: opts.Verbose,
|
||||||
|
Force: opts.Force,
|
||||||
|
Incremental: opts.Incremental,
|
||||||
|
CompareMode: mode,
|
||||||
|
Logger: log,
|
||||||
|
Progress: progressCallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
// main 함수에서 해당 부분을 다음과 같이 교체:
|
||||||
|
if opts.Verbose {
|
||||||
|
printOptions(opts, backupPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backup.RunBackup(ctx, opts.Src, backupPath, backupOpts); err != nil {
|
||||||
|
if err == context.Canceled {
|
||||||
|
fmt.Println("\n백업이 취소되었습니다")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n백업 중 오류 발생:", err)
|
||||||
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
backupPath = fmt.Sprintf("%s/snapshots/%s", dst, snapshotFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("원본 경로:", src)
|
if opts.Verbose {
|
||||||
fmt.Println("최종 백업 경로:", backupPath)
|
fmt.Println("\n백업이 성공적으로 완료되었습니다")
|
||||||
if *dryRun {
|
|
||||||
fmt.Println("Dry-run 모드 활성화됨")
|
|
||||||
}
|
|
||||||
if *verbose {
|
|
||||||
fmt.Println("Verbose 모드 활성화됨")
|
|
||||||
}
|
|
||||||
if *force {
|
|
||||||
fmt.Println("Force 모드 활성화됨")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := backup.RunBackup(src, backupPath, *dryRun, *verbose, *force); err != nil {
|
|
||||||
fmt.Println("백업 중 오류 발생:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,263 @@
|
|||||||
package backup
|
package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/copy"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/constants"
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/copy"
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/logger"
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/path"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunBackup: 백업 실행 함수
|
type CompareMode int
|
||||||
func RunBackup(src, dst string, dryRun, verbose, force bool) error {
|
|
||||||
if !dryRun {
|
const (
|
||||||
// 백업 폴더 생성
|
CompareTime CompareMode = iota
|
||||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
CompareHash
|
||||||
return fmt.Errorf("백업 폴더 생성 실패: %w", err)
|
)
|
||||||
|
|
||||||
|
type Progress struct {
|
||||||
|
CurrentFile string
|
||||||
|
ProcessedFiles int
|
||||||
|
TotalFiles int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressCallback func(Progress)
|
||||||
|
|
||||||
|
type BackupOptions struct {
|
||||||
|
DryRun bool
|
||||||
|
Verbose bool
|
||||||
|
Force bool
|
||||||
|
Incremental bool
|
||||||
|
CompareMode CompareMode
|
||||||
|
Logger *logger.Logger
|
||||||
|
Progress ProgressCallback
|
||||||
|
dirCache *path.DirCache
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupMeta struct {
|
||||||
|
LastBackup time.Time `json:"lastBackup"`
|
||||||
|
Files map[string]string `json:"files"` // 파일 경로 -> 해시 또는 수정 시간
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBackupMeta(dst string) (*BackupMeta, error) {
|
||||||
|
metaPath := filepath.Join(dst, constants.MetaFileName)
|
||||||
|
data, err := os.ReadFile(metaPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &BackupMeta{
|
||||||
|
Files: make(map[string]string),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("메타 파일을 읽을 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta BackupMeta
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
return nil, fmt.Errorf("메타 파일을 파싱할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Files == nil {
|
||||||
|
meta.Files = make(map[string]string)
|
||||||
|
}
|
||||||
|
return &meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBackupMeta(dst string, meta *BackupMeta) error {
|
||||||
|
metaPath := filepath.Join(dst, constants.MetaFileName)
|
||||||
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("메타 데이터를 직렬화할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(metaPath, data, constants.DefaultFileMode); err != nil {
|
||||||
|
return fmt.Errorf("메타 파일을 저장할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateFileHash(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func(f *os.File) {
|
||||||
|
err := f.Close()
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
}
|
||||||
|
}(f)
|
||||||
|
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunBackup(ctx context.Context, src, dst string, opts BackupOptions) error {
|
||||||
|
if opts.dirCache == nil {
|
||||||
|
opts.dirCache = path.NewDirCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상 디렉토리 생성
|
||||||
|
if !opts.DryRun {
|
||||||
|
// 대상 디렉토리 생성
|
||||||
|
if err := opts.dirCache.EnsureDir(dst); err != nil {
|
||||||
|
return fmt.Errorf("대상 디렉토리를 생성할 수 없습니다: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
// 증분 백업을 위한 메타데이터 로드
|
||||||
|
var meta *BackupMeta
|
||||||
|
var err error
|
||||||
|
if opts.Incremental {
|
||||||
|
meta, err = loadBackupMeta(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 목록 수집
|
||||||
|
var files []string
|
||||||
|
err = filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
relPath, err := filepath.Rel(src, path)
|
// 디렉토리는 건너뜀
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
targetPath := filepath.Join(dst, relPath)
|
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
if verbose {
|
|
||||||
fmt.Println("디렉터리 생성:", targetPath)
|
|
||||||
}
|
|
||||||
if !dryRun {
|
|
||||||
return os.MkdirAll(targetPath, info.Mode())
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if dryRun {
|
// 메타파일은 건너뜀
|
||||||
fmt.Println("복사 예정 파일:", targetPath)
|
if filepath.Base(path) == constants.MetaFileName {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// force 옵션이 없으면 수정 시간 비교
|
files = append(files, path)
|
||||||
if !force {
|
|
||||||
dstInfo, err := os.Stat(targetPath)
|
|
||||||
if err == nil && !info.ModTime().After(dstInfo.ModTime()) {
|
|
||||||
if verbose {
|
|
||||||
fmt.Println("복사 스킵 (업데이트 필요 없음):", path)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
fmt.Println("파일 복사:", path, "→", targetPath)
|
|
||||||
}
|
|
||||||
return copy.RunCopy(path, targetPath)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("파일 목록을 수집할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 진행 상황 초기화
|
||||||
|
progress := Progress{
|
||||||
|
TotalFiles: len(files),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 복사 함수
|
||||||
|
copyFile := func(srcPath string, dstPath string) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상 디렉토리 생성
|
||||||
|
dstDir := filepath.Dir(dstPath)
|
||||||
|
if err := opts.dirCache.EnsureDir(dstDir); err != nil {
|
||||||
|
return fmt.Errorf("대상 디렉토리를 생성할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy.RunCopy(srcPath, dstPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 파일 처리
|
||||||
|
for i, srcPath := range files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return context.Canceled
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// 진행 상황 업데이트
|
||||||
|
progress.CurrentFile = srcPath
|
||||||
|
progress.ProcessedFiles = i + 1
|
||||||
|
if opts.Progress != nil {
|
||||||
|
opts.Progress(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 경로 계산
|
||||||
|
relPath, err := filepath.Rel(src, srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("상대 경로를 계산할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dstPath := filepath.Join(dst, relPath)
|
||||||
|
|
||||||
|
// 파일이 이미 존재하는지 확인
|
||||||
|
dstInfo, err := os.Stat(dstPath)
|
||||||
|
fileExists := err == nil
|
||||||
|
|
||||||
|
if fileExists && !opts.Force {
|
||||||
|
if opts.Incremental {
|
||||||
|
// 증분 백업 모드에서는 메타데이터를 확인
|
||||||
|
srcInfo, err := os.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("원본 파일 정보를 읽을 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldValue, exists := meta.Files[relPath]
|
||||||
|
var newValue string
|
||||||
|
|
||||||
|
switch opts.CompareMode {
|
||||||
|
case CompareTime:
|
||||||
|
newValue = srcInfo.ModTime().String()
|
||||||
|
if exists && oldValue == newValue {
|
||||||
|
opts.Logger.Printf("건너뜀 (시간 동일): %s\n", relPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case CompareHash:
|
||||||
|
newValue, err = calculateFileHash(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("파일 해시를 계산할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
if exists && oldValue == newValue {
|
||||||
|
opts.Logger.Printf("건너뜀 (해시 동일): %s\n", relPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.Files[relPath] = newValue
|
||||||
|
} else {
|
||||||
|
// 일반 모드에서는 크기와 수정 시간만 비교
|
||||||
|
srcInfo, err := os.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("원본 파일 정보를 읽을 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srcInfo.Size() == dstInfo.Size() && srcInfo.ModTime().Equal(dstInfo.ModTime()) {
|
||||||
|
opts.Logger.Printf("건너뜀 (동일): %s\n", relPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 복사
|
||||||
|
if err := copyFile(srcPath, dstPath); err != nil {
|
||||||
|
return fmt.Errorf("파일을 복사할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Logger.Printf("복사됨: %s\n", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 증분 백업 메타데이터 저장
|
||||||
|
if opts.Incremental && !opts.DryRun {
|
||||||
|
meta.LastBackup = time.Now()
|
||||||
|
if err := saveBackupMeta(dst, meta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
28
internal/constants/constants.go
Normal file
28
internal/constants/constants.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// 그룹화 옵션
|
||||||
|
const (
|
||||||
|
GroupByYear = "year"
|
||||||
|
GroupByMonth = "mon"
|
||||||
|
GroupByDay = "day"
|
||||||
|
GroupByHour = "hour"
|
||||||
|
GroupByMin = "min"
|
||||||
|
GroupBySec = "sec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 비교 모드 옵션
|
||||||
|
const (
|
||||||
|
CompareTime = "time"
|
||||||
|
CompareHash = "hash"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 버퍼 크기
|
||||||
|
const (
|
||||||
|
CopyBufferSize = 1024 * 1024 // 1MB
|
||||||
|
)
|
||||||
|
const (
|
||||||
|
DefaultGroupBy = "day"
|
||||||
|
DefaultCompare = "time"
|
||||||
|
MetaFileName = ".backup_meta.json"
|
||||||
|
DefaultFileMode = 0755
|
||||||
|
)
|
||||||
@@ -1,48 +1,70 @@
|
|||||||
package copy
|
package copy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CopyFile 복사 함수: srcFile → dstFile
|
var bufferPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return make([]byte, constants.CopyBufferSize)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func RunCopy(srcFile, dstFile string) error {
|
func RunCopy(srcFile, dstFile string) error {
|
||||||
|
// 소스 파일 정보 확인
|
||||||
|
srcInfo, err := os.Stat(srcFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("source file does not exist: %s", srcFile)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to get source file info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소스 파일 열기
|
||||||
src, err := os.Open(srcFile)
|
src, err := os.Open(srcFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to open source file: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
defer func(src *os.File) {
|
if cerr := src.Close(); cerr != nil && err == nil {
|
||||||
err := src.Close()
|
err = fmt.Errorf("failed to close source file: %w", cerr)
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}(src)
|
}()
|
||||||
|
|
||||||
|
// 대상 파일 생성
|
||||||
dst, err := os.Create(dstFile)
|
dst, err := os.Create(dstFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create destination file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(dst *os.File) {
|
defer func() {
|
||||||
err := dst.Close()
|
if cerr := dst.Close(); cerr != nil && err == nil {
|
||||||
if err != nil {
|
err = fmt.Errorf("failed to close destination file: %w", cerr)
|
||||||
|
|
||||||
}
|
}
|
||||||
}(dst)
|
}()
|
||||||
|
|
||||||
_, err = io.Copy(dst, src)
|
// 버퍼 풀에서 가져오기
|
||||||
if err != nil {
|
buf := bufferPool.Get().([]byte)
|
||||||
return err
|
defer bufferPool.Put(buf)
|
||||||
|
|
||||||
|
// 파일 내용 복사
|
||||||
|
if _, err = io.CopyBuffer(dst, src, buf); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy file contents: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 원본 파일의 권한을 복사
|
// 파일 권한과 시간 정보 복사
|
||||||
info, err := os.Stat(srcFile)
|
if err := os.Chmod(dstFile, srcInfo.Mode()); err != nil {
|
||||||
if err == nil {
|
return fmt.Errorf("failed to copy file permissions: %w", err)
|
||||||
err := os.Chmod(dstFile, info.Mode())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 수정 시간과 접근 시간 복사
|
||||||
|
if err := os.Chtimes(dstFile, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy file timestamps: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
32
internal/logger/logger.go
Normal file
32
internal/logger/logger.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
verbose bool
|
||||||
|
writer *bufio.Writer
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(verbose bool) *Logger {
|
||||||
|
return &Logger{
|
||||||
|
verbose: verbose,
|
||||||
|
writer: bufio.NewWriter(os.Stdout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Printf(format string, args ...interface{}) {
|
||||||
|
if !l.verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
_, _ = fmt.Fprintf(l.writer, format, args...)
|
||||||
|
_ = l.writer.Flush()
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -2,23 +2,36 @@ package path
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.lhk.o-r.kr/freerer2/simple_backup/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetGroupByFolder: group-by 옵션에 따라 백업 경로 생성
|
// GetGroupByFolder generates a backup path based on the groupBy option and time.
|
||||||
|
// If groupBy is invalid or empty, it returns the basePath unchanged.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - basePath: the base directory path for backup
|
||||||
|
// - groupBy: grouping option (year, mon, day, hour, min, sec)
|
||||||
|
// - t: time to use for path generation
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: generated path based on groupBy option
|
||||||
func GetGroupByFolder(basePath, groupBy string, t time.Time) string {
|
func GetGroupByFolder(basePath, groupBy string, t time.Time) string {
|
||||||
switch groupBy {
|
switch groupBy {
|
||||||
case "year":
|
case constants.GroupByYear:
|
||||||
return fmt.Sprintf("%s/%d", basePath, t.Year())
|
return fmt.Sprintf("%s/%d", basePath, t.Year())
|
||||||
case "mon":
|
case constants.GroupByMonth:
|
||||||
return fmt.Sprintf("%s/%d%02d", basePath, t.Year(), t.Month())
|
return fmt.Sprintf("%s/%d%02d", basePath, t.Year(), t.Month())
|
||||||
case "day":
|
case constants.GroupByDay:
|
||||||
return fmt.Sprintf("%s/%d%02d%02d", basePath, t.Year(), t.Month(), t.Day())
|
return fmt.Sprintf("%s/%d%02d%02d", basePath, t.Year(), t.Month(), t.Day())
|
||||||
case "hour":
|
case constants.GroupByHour:
|
||||||
return fmt.Sprintf("%s/%d%02d%02d_%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour())
|
return fmt.Sprintf("%s/%d%02d%02d_%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour())
|
||||||
case "min":
|
case constants.GroupByMin:
|
||||||
return fmt.Sprintf("%s/%d%02d%02d_%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute())
|
return fmt.Sprintf("%s/%d%02d%02d_%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute())
|
||||||
case "sec":
|
case constants.GroupBySec:
|
||||||
return fmt.Sprintf("%s/%d%02d%02d_%02d%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
|
return fmt.Sprintf("%s/%d%02d%02d_%02d%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
|
||||||
case "":
|
case "":
|
||||||
return basePath
|
return basePath
|
||||||
@@ -27,3 +40,39 @@ func GetGroupByFolder(basePath, groupBy string, t time.Time) string {
|
|||||||
return basePath
|
return basePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 디렉토리 캐시
|
||||||
|
type DirCache struct {
|
||||||
|
sync.RWMutex
|
||||||
|
exists map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDirCache() *DirCache {
|
||||||
|
return &DirCache{
|
||||||
|
exists: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DirCache) EnsureDir(path string) error {
|
||||||
|
c.RLock()
|
||||||
|
if c.exists[path] {
|
||||||
|
c.RUnlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.RUnlock()
|
||||||
|
|
||||||
|
c.Lock()
|
||||||
|
defer c.Unlock()
|
||||||
|
|
||||||
|
// 다시 확인 (다른 고루틴이 생성했을 수 있음)
|
||||||
|
if c.exists[path] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(path, constants.DefaultFileMode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.exists[path] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user