diff --git a/.claude/commands/init-workflow.md b/.claude/commands/init-worktree.md similarity index 75% rename from .claude/commands/init-workflow.md rename to .claude/commands/init-worktree.md index c9ff9d6..7e77d74 100644 --- a/.claude/commands/init-workflow.md +++ b/.claude/commands/init-worktree.md @@ -1,11 +1,11 @@ -# init-workflow +# init-worktree Git worktree를 자동으로 생성하는 커맨드입니다. 브랜치명을 입력받아 자동으로: -1. 브랜치명은 사용자 입력 그대로 사용 (예: `20260127_#16_...`) -2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) -3. 폴더명만 특수문자(`#` 포함)를 `_`로 변환하여 생성 (예: `20260127_16_...`) +1. 브랜치명에서 `#` 문자 제거 (Git 브랜치명으로 사용) +2. 브랜치가 없으면 리모트(origin) 확인 → 있으면 tracking 브랜치로 가져오기, 없으면 현재 브랜치에서 새로 생성 +3. 브랜치명의 특수문자를 `_`로 변환하여 폴더명 생성 4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) 5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) 6. 이미 존재하면 경로만 출력 @@ -13,7 +13,7 @@ Git worktree를 자동으로 생성하는 커맨드입니다. ## 사용법 ``` -/init-workflow +/init-worktree 20260120_#163_Github_Projects_에_대한_템플릿_개발_필요_및_관련_Sync_워크플로우_개발_필요 ``` @@ -21,7 +21,7 @@ Git worktree를 자동으로 생성하는 커맨드입니다. ## 실행 로직 1. 사용자 입력에서 두 번째 줄의 브랜치명 추출 -2. 브랜치명은 그대로 사용 (임시 Python 스크립트에 포함) +2. 브랜치명에서 `#` 문자 제거 3. 임시 Python 스크립트 파일 생성 (인코딩 문제 해결) 4. Python 스크립트 실행 (worktree 생성 + 설정 파일 복사) 5. 임시 파일 자동 삭제 @@ -37,22 +37,20 @@ Git worktree를 자동으로 생성하는 커맨드입니다. 브랜치명이 제공된 경우: 1. 프로젝트 루트로 이동 2. Git 긴 경로 지원 활성화: `git config --global core.longpaths true` (최초 1회만 실행) -3. 브랜치명은 사용자 입력 그대로 사용 (예: `20260116_#432_...` 그대로 유지) -4. 임시 Python 스크립트 파일 생성: - - 파일명: `init_worktree_temp_{timestamp}.py` - - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결) - - worktree 생성 로직 포함 -5. **Python 스크립트 실행** (Windows에서는 `-X utf8` 플래그 필수): +3. 임시 Python 스크립트 파일 생성: + - 파일명: `init_worktree_temp_{timestamp}.py` + - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결, `#` 문자 유지) + - worktree 생성 로직 포함 +4. **Python 스크립트 실행** (Windows에서는 `-X utf8` 플래그 필수): ```bash python -X utf8 init_worktree_temp_{timestamp}.py ``` -6. 임시 파일 삭제 -7. 결과 출력 -8. 에이전트가 `.gitignore` 분석 후 민감 파일 복사 +5. 임시 파일 삭제 +6. 결과 출력 +7. 에이전트가 `.gitignore` 분석 후 민감 파일 복사 **중요**: -- **브랜치명 처리**: 브랜치명은 사용자 입력 그대로 사용됩니다 (`#` 문자 포함) -- **폴더명 처리**: 폴더명만 `normalize_branch_name()` 함수로 특수문자(`#` 포함)를 `_`로 변환합니다 +- **브랜치명 처리**: 브랜치명은 `#` 문자를 포함하여 **원본 그대로** 전달됩니다. `#`은 **폴더명 생성 시에만** `_`로 변환됩니다. - **인코딩 문제 해결**: Python 스크립트 파일에 브랜치명을 직접 포함시켜 Windows PowerShell 인코딩 문제 회피 - **Windows UTF-8 모드**: Python 실행 시 `-X utf8` 플래그 사용 필수 - **설정 파일 자동 복사**: worktree 생성 후 에이전트가 동적으로 파일 복사 @@ -67,7 +65,7 @@ git config --global core.longpaths true # Python UTF-8 모드로 실행 (Windows 한글 인코딩 문제 해결) python -X utf8 init_worktree_temp.py -# 입력 브랜치명: 20260116_#432_UX_개선_및_페이지_디자인_수정 +# 브랜치명: 20260116_#432_UX_개선_및_페이지_디자인_수정 # → Git 브랜치: 20260116_#432_UX_개선_및_페이지_디자인_수정 (# 유지) # → 폴더명: 20260116_432_UX_개선_및_페이지_디자인_수정 (# → _ 변환) ``` @@ -83,7 +81,7 @@ import glob # 프로젝트 루트로 이동 os.chdir('프로젝트_루트_경로') -# 브랜치명 (사용자 입력 그대로) +# 브랜치명 (원본 그대로, # 유지) branch_name = '20260116_#432_UX_개선_및_페이지_디자인_수정' # worktree_manager 실행 @@ -96,18 +94,18 @@ exit_code = worktree_manager.main() # worktree 경로를 환경변수로 설정 (에이전트가 파일 복사에 사용) if exit_code == 0: - import subprocess - result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], + import subprocess + result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], capture_output=True, text=True, encoding='utf-8') - lines = result.stdout.split('\n') - worktree_path = None - for i, line in enumerate(lines): - if line.startswith(f'branch refs/heads/{branch_name}'): - worktree_path = lines[i-1].replace('worktree ', '') - break - - if worktree_path: - print(f'📍 WORKTREE_PATH={worktree_path}') + lines = result.stdout.split('\n') + worktree_path = None + for i, line in enumerate(lines): + if line.startswith(f'branch refs/heads/{branch_name}'): + worktree_path = lines[i-1].replace('worktree ', '') + break + + if worktree_path: + print(f'📍 WORKTREE_PATH={worktree_path}') sys.exit(exit_code) ``` diff --git a/.claude/commands/issue.md b/.claude/commands/issue.md new file mode 100644 index 0000000..aa13510 --- /dev/null +++ b/.claude/commands/issue.md @@ -0,0 +1,280 @@ +# Issue Mode + +당신은 GitHub 이슈 작성 전문가입니다. 사용자의 대략적인 설명을 받아 **GitHub 이슈 템플릿에 맞는 제목과 본문을 자동으로 작성**합니다. + +## 절대 금지 사항 + +- ❌ 코드적인 내용 작성 금지 (구현 방법, 코드 예시 등 포함하지 않음) +- ❌ 긴급(🔥) 태그를 임의로 추가하지 않음 (사용자가 직접 "긴급"이라고 말한 경우에만) +- ❌ 담당자 내용을 임의로 채우지 않음 (템플릿 기본값 그대로 유지) + +## 사용자 입력 + +$ARGUMENTS + +--- + +## 동작 프로세스 + +### 1단계: 이슈 타입 자동 판단 + +사용자 입력을 분석하여 아래 4가지 타입 중 하나를 판단합니다: + +| 타입 | 판단 키워드/상황 | 템플릿 | +|------|----------------|--------| +| **버그** | 안 됨, 에러, 깨짐, 오류, 크래시, 안 됨, 문제, 장애, 실패, 로그 | `bug_report` | +| **기능 (추가/개선/요청)** | 추가, 만들어야, 새로, 구현, 필요, 개선, 수정, 변경, 요청, 제안 | `feature_request` | +| **디자인** | 디자인, UI, UX, 폰트, 색상, 레이아웃, 화면, 아이콘 | `design_request` | +| **QA/시험** | 테스트, QA, 시험, 검증, 확인 | `qa_request` | + +**기능 타입 세분류** (제목 이모지 결정): +- ⚙️`[기능추가]` : 완전히 새로운 기능 +- 🚀`[기능개선]` : 기존 기능 개선/수정 +- 🔧`[기능요청]` : 제안/요청 수준 + +### 2단계: 부족한 정보 질문 + +**반드시 질문하는 경우:** +- 타입 판단이 애매할 때 (버그인지 개선인지 등) +- 카테고리 태그를 특정할 수 없을 때 + +**질문하지 않는 경우 (알아서 판단):** +- 문맥상 명확한 것 (로그인 에러 → 버그) +- 환경 정보 등 선택 항목 (비워두면 됨) +- 담당자 (항상 기본값 유지) + +**질문 스타일:** 짧고 직관적으로, 가능하면 선택지 제공 + +### 3단계: 이슈 파일 저장 + +판단된 타입에 맞는 템플릿을 사용하여 **제목 + 본문**을 `.issue/` 폴더에 마크다운 파일로 저장합니다. + +### 이슈 파일 생성 로직 + +1. **.issue 폴더 확인** + - `.issue/` 폴더가 없으면 자동 생성 + +2. **파일명 생성** + - 형식: `#[YYYYMMDD]_[순번]_[이슈타입]_[간단한설명].md` + - `#` 접두사: 파일 정렬 및 이슈 파일 식별용 + - 날짜: 현재 날짜 (YYYYMMDD 형식) + - 순번: 같은 날짜 내 `.issue/` 폴더의 기존 파일 수를 확인하여 3자리 자동 증가 (001, 002, ...) + - 이슈타입: `버그`, `기능추가`, `기능개선`, `기능요청`, `디자인`, `시험요청` + - 설명: 한글/영문, 언더스코어로 단어 구분 + - 특수문자 제거 및 안전한 파일명으로 변환 + - 예시: `#20260214_001_버그_채팅방_중복생성_오류.md`, `#20260214_002_기능추가_물품삭제_확인_다이얼로그.md` + +3. **파일 내용** + - 첫 줄: 제목 그대로 (마크다운 `#` 없이, GitHub 이슈 제목에 바로 복사-붙여넣기 가능) + - 빈 줄 + - 나머지: 본문 (GitHub 이슈 본문으로 복사용) + +4. **파일 저장** + - `.issue/` 폴더에 직접 저장 + - Git 추적 안 됨 (`.gitignore`에 `/.issue` 등록) + +### 출력 + +1. 저장된 파일 경로 표시 +2. 이슈 내용 요약 표시 +3. "GitHub에 이슈를 생성하려면 위 제목과 본문을 복사해서 붙여넣으세요" 안내 + +--- + +## 제목 형식 + +기존 이슈 패턴을 준수합니다: + +``` +❗[버그][카테고리] 설명 +⚙️[기능추가][카테고리] 설명 +🚀[기능개선][카테고리] 설명 +🔧[기능요청][카테고리] 설명 +🎨[디자인][카테고리] 설명 +🔍[시험요청][카테고리] 설명 +``` + +- `[카테고리]`는 관련 영역을 넣음 (예: `[로그인]`, `[Flutter]`, `[CICD]`, `[마법사]`, `[CustomCommand]` 등) +- 카테고리는 여러 개 가능 (예: `[버그][Flutter][빌드]`) +- 사용자가 "긴급"이라고 직접 말한 경우에만 `🔥[긴급]` 추가 + +--- + +## 템플릿별 출력 형식 + +### 버그 이슈 (bug_report) + +```markdown +## 제목 +❗[버그][카테고리] 설명 + +## 본문 + +🗒️ 설명 +--- + +- {사용자 입력 기반으로 버그 현상 설명} + +🔄 재현 방법 +--- + +1. {재현 단계 1} +2. {재현 단계 2} +3. {결과 확인} + +📸 참고 자료 +--- + +{사용자가 로그나 스크린샷을 제공한 경우 여기에 정리, 없으면 비워둠} + +✅ 예상 동작 +--- + +- {정상적으로 동작해야 하는 모습} + +⚙️ 환경 정보 +--- + +- **OS**: +- **브라우저**: +- **기기**: + +🙋‍♂️ 담당자 +--- + +- **백엔드**: 이름 +- **프론트엔드**: 이름 +- **디자인**: 이름 +``` + +### 기능 요청/추가/개선 이슈 (feature_request) + +```markdown +## 제목 +⚙️/🚀/🔧[기능추가/개선/요청][카테고리] 설명 + +## 본문 + +📝 현재 문제점 +--- + +- {현재 어떤 부분이 부족하거나 필요한지} + +🛠️ 해결 방안 / 제안 기능 +--- + +- {제안하는 기능이나 해결 방안} + +🙋‍♂️ 담당자 +--- + +- 백엔드: 이름 +- 프론트엔드: 이름 +- 디자인: 이름 +``` + +**참고**: 기능추가/기능개선인 경우 필요하다 판단되면 `⚙️ 작업 내용` 섹션을 추가할 수 있음 (코드 내용이 아닌 작업 항목 수준으로): + +```markdown +⚙️ 작업 내용 +--- + +- {작업 항목 1} +- {작업 항목 2} +``` + +### 디자인 요청 이슈 (design_request) + +```markdown +## 제목 +🎨[디자인][카테고리] 설명 + +## 본문 + +🖌️ 요청 내용 +--- + +- {디자인 요청 내용} + +🎯 기대 결과 +--- + +- {디자인 적용 후 예상 결과} + +📋 참고 자료 +--- + +{참고 링크, 이미지 등} + +💡 추가 요청 사항 +--- + +- {추가 고려 사항} + +🙋‍♂️ 담당자 +--- + +- 백엔드: 이름 +- 프론트엔드: 이름 +- 디자인: 이름 +``` + +### QA/시험 이슈 (qa_request) + +```markdown +## 제목 +🔍[시험요청][카테고리] 설명 + +## 본문 + +🔗 ISSUE 정보 +--- + +- {관련 이슈 번호/링크} + +🔗 PR 정보 +--- + +- {관련 PR 번호/링크} + +🧩 시험 대상 +--- + +- {테스트할 기능/수정사항} + +📋 테스트 시나리오 +--- + +1. {테스트 케이스 1} +2. {테스트 케이스 2} +3. {테스트 케이스 3} + +⚙️ 테스트 환경 +--- + +- **프로젝트 Version**: +- **OS**: +- **브라우저**: +- **기기**: + +🙋‍♂️ 담당자 +--- + +- **시험담당**: 이름 +``` + +--- + +## 작성 원칙 + +1. **간결하게**: 불필요한 설명 없이 핵심만 작성 +2. **코드 없이**: 기능/현상 중심으로만 작성, 구현 방법이나 코드 언급 금지 +3. **템플릿 준수**: 위 템플릿 구조를 정확히 따름 +4. **적절한 추론**: 사용자가 대충 말해도 문맥에서 파악 가능한 건 알아서 채움 +5. **필요하면 질문**: 정말 모르겠는 것만 짧게 질문 +6. **담당자 기본값 유지**: 담당자는 항상 "이름"으로 유지 +7. **보강 가능**: 필요하다 판단되면 내용을 적절히 추가해도 됨 + +--- + +**목표**: "사용자가 대충 설명해도, 깔끔한 GitHub 이슈를 바로 만들 수 있게" diff --git a/.claude/scripts/worktree_manager.py b/.claude/scripts/worktree_manager.py index 6e474e6..4ff09c9 100644 --- a/.claude/scripts/worktree_manager.py +++ b/.claude/scripts/worktree_manager.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -Git Worktree Manager v1.0.4 +Git Worktree Manager v1.1.0 Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. -브랜치가 없으면 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. +브랜치가 없으면 리모트(origin) 확인 후 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. 사용법: macOS/Linux: @@ -32,21 +32,21 @@ # Windows 인코딩 문제 해결 - stdout/stderr를 UTF-8로 래핑 if platform.system() == 'Windows': - try: - # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 - if hasattr(sys.stdout, 'buffer'): - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - if hasattr(sys.stderr, 'buffer'): - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') - except Exception: - pass # 래핑 실패 시 무시 + try: + # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 + if hasattr(sys.stdout, 'buffer'): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + if hasattr(sys.stderr, 'buffer'): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + except Exception: + pass # 래핑 실패 시 무시 # =================================================================== # 상수 정의 # =================================================================== -VERSION = "1.0.4" +VERSION = "1.1.0" # Windows 환경 감지 IS_WINDOWS = platform.system() == 'Windows' @@ -64,89 +64,108 @@ # =================================================================== def get_branch_name() -> str: - """ - 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) - - Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 - 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. - - Returns: - str: 브랜치명 (비어있을 수 있음) - """ - if IS_WINDOWS: - # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) - branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') - if branch_name_raw: - try: - branch_name = branch_name_raw.strip() - if branch_name: - return branch_name - except Exception: - pass - - # 방법 2: 임시 파일에서 읽기 - temp_file = os.environ.get('BRANCH_NAME_FILE', '') - if temp_file and os.path.exists(temp_file): - try: - encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] - for encoding in encodings: - try: - with open(temp_file, 'r', encoding=encoding) as f: - branch_name = f.read().strip() - if branch_name: - return branch_name - except (UnicodeDecodeError, UnicodeError): - continue - except Exception: - pass - - # 방법 3: stdin에서 읽기 시도 - if not sys.stdin.isatty(): - try: - branch_name = sys.stdin.read().strip() - if branch_name: - return branch_name - except Exception: - pass - - # 기본: sys.argv에서 받기 - if len(sys.argv) >= 2: - return sys.argv[1].strip() - - return '' + """ + 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) + + Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 + 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. + + Returns: + str: 브랜치명 (비어있을 수 있음) + """ + if IS_WINDOWS: + # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) + # Windows에서 환경 변수는 시스템 기본 인코딩을 사용하므로 UTF-8로 디코딩 시도 + branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') + if branch_name_raw: + try: + # 환경 변수가 이미 올바른 인코딩인 경우 + branch_name = branch_name_raw.strip() + # 한글이 깨져있는지 확인 (깨진 경우 복구 시도) + if '\xef\xbf\xbd' in branch_name.encode('utf-8', errors='replace').decode('utf-8', errors='replace'): + # 깨진 경우, 시스템 인코딩으로 디코딩 후 UTF-8로 재인코딩 시도 + import locale + sys_encoding = locale.getpreferredencoding() + branch_name = branch_name_raw.encode(sys_encoding, errors='replace').decode('utf-8', errors='replace').strip() + else: + branch_name = branch_name.strip() + if branch_name: + return branch_name + except Exception: + # 인코딩 변환 실패 시 원본 사용 + branch_name = branch_name_raw.strip() + if branch_name: + return branch_name + + # 방법 2: 임시 파일에서 읽기 (init-worktree에서 파일 생성 후 전달) + temp_file = os.environ.get('BRANCH_NAME_FILE', '') + if temp_file and os.path.exists(temp_file): + try: + # 여러 인코딩 시도: UTF-8, UTF-8 with BOM, 시스템 기본 인코딩 + encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] + branch_name = None + for encoding in encodings: + try: + with open(temp_file, 'r', encoding=encoding) as f: + branch_name = f.read().strip() + if branch_name and not any(ord(c) > 0xFFFF for c in branch_name if ord(c) > 0x7F): + # 한글이 제대로 읽혔는지 확인 (깨진 문자가 없는지) + break + except (UnicodeDecodeError, UnicodeError): + continue + + if branch_name: + return branch_name + except Exception as e: + print_warning(f"브랜치명 파일 읽기 실패: {e}") + + # 방법 3: stdin에서 읽기 시도 (파이프 입력인 경우) + if not sys.stdin.isatty(): + try: + branch_name = sys.stdin.read().strip() + if branch_name: + return branch_name + except Exception: + pass + + # 기본: sys.argv에서 받기 (macOS/Linux 또는 Windows에서도 인자로 전달된 경우) + if len(sys.argv) >= 2: + return sys.argv[1].strip() + + return '' def print_header(): - """헤더 출력""" - print("━" * 60) - print(f"🌿 Git Worktree Manager v{VERSION}") - print("━" * 60) - print() + """헤더 출력""" + print("━" * 60) + print(f"🌿 Git Worktree Manager v{VERSION}") + print("━" * 60) + print() def print_step(emoji: str, message: str): - """단계별 메시지 출력""" - print(f"{emoji} {message}") + """단계별 메시지 출력""" + print(f"{emoji} {message}") def print_error(message: str): - """에러 메시지 출력""" - print(f"❌ 에러: {message}", file=sys.stderr) + """에러 메시지 출력""" + print(f"❌ 에러: {message}", file=sys.stderr) def print_success(message: str): - """성공 메시지 출력""" - print(f"✅ {message}") + """성공 메시지 출력""" + print(f"✅ {message}") def print_info(message: str): - """정보 메시지 출력""" - print(f"ℹ️ {message}") + """정보 메시지 출력""" + print(f"ℹ️ {message}") def print_warning(message: str): - """경고 메시지 출력""" - print(f"⚠️ {message}") + """경고 메시지 출력""" + print(f"⚠️ {message}") # =================================================================== @@ -154,239 +173,294 @@ def print_warning(message: str): # =================================================================== def run_git_command(args: list, check: bool = True) -> Tuple[bool, str, str]: - """ - Git 명령어 실행 - - Args: - args: Git 명령어 인자 리스트 (예: ['branch', '--list']) - check: 에러 발생 시 예외를 발생시킬지 여부 - - Returns: - Tuple[bool, str, str]: (성공 여부, stdout, stderr) - """ - try: - result = subprocess.run( - ['git'] + args, - capture_output=True, - text=True, - encoding='utf-8', - check=check - ) - return True, result.stdout.strip(), result.stderr.strip() - except subprocess.CalledProcessError as e: - return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" - except Exception as e: - return False, "", str(e) + """ + Git 명령어 실행 + + Args: + args: Git 명령어 인자 리스트 (예: ['branch', '--list']) + check: 에러 발생 시 예외를 발생시킬지 여부 + + Returns: + Tuple[bool, str, str]: (성공 여부, stdout, stderr) + """ + try: + result = subprocess.run( + ['git'] + args, + capture_output=True, + text=True, + encoding='utf-8', + check=check + ) + return True, result.stdout.strip(), result.stderr.strip() + except subprocess.CalledProcessError as e: + return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" + except Exception as e: + return False, "", str(e) def check_and_enable_longpaths() -> bool: - """ - Windows에서 Git 긴 경로 지원 확인 및 활성화 - - Returns: - bool: 긴 경로 지원이 활성화되어 있으면 True - """ - if not IS_WINDOWS: - return True - - # 현재 설정 확인 - success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) - if success and stdout.strip().lower() == 'true': - return True - - # 긴 경로 지원 활성화 - print_info("Windows 긴 경로 지원을 활성화합니다...") - success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) - if success: - print_success("긴 경로 지원이 활성화되었습니다.") - return True - else: - print_warning(f"긴 경로 지원 활성화 실패: {stderr}") - print_warning("수동으로 실행하세요: git config --global core.longpaths true") - return False + """ + Windows에서 Git 긴 경로 지원 확인 및 활성화 + + Returns: + bool: 긴 경로 지원이 활성화되어 있으면 True + """ + if not IS_WINDOWS: + return True + + # 현재 설정 확인 + success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) + if success and stdout.strip().lower() == 'true': + return True + + # 긴 경로 지원 활성화 + print_info("Windows 긴 경로 지원을 활성화합니다...") + success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) + if success: + print_success("긴 경로 지원이 활성화되었습니다.") + return True + else: + print_warning(f"긴 경로 지원 활성화 실패: {stderr}") + print_warning("수동으로 실행하세요: git config --global core.longpaths true") + return False def is_git_repository() -> bool: - """현재 디렉토리가 Git 저장소인지 확인""" - success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) - return success + """현재 디렉토리가 Git 저장소인지 확인""" + success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) + return success def get_git_root() -> Optional[Path]: - """Git 저장소 루트 경로 반환""" - success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) - if success and stdout: - return Path(stdout) - return None + """Git 저장소 루트 경로 반환""" + success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) + if success and stdout: + return Path(stdout) + return None def get_current_branch() -> Optional[str]: - """현재 체크아웃된 브랜치명 반환""" - success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) - if success and stdout: - return stdout - return None + """현재 체크아웃된 브랜치명 반환""" + success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) + if success and stdout: + return stdout + return None def branch_exists(branch_name: str) -> bool: - """ - 브랜치 존재 여부 확인 - - Args: - branch_name: 확인할 브랜치명 - - Returns: - bool: 브랜치가 존재하면 True - """ - success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) - if success and stdout: - # 출력 형식: " branch_name" 또는 "* branch_name" - branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] - return branch_name in branches - return False + """ + 로컬 브랜치 존재 여부 확인 + + Args: + branch_name: 확인할 브랜치명 + + Returns: + bool: 로컬 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) + if success and stdout: + # 출력 형식: " branch_name" 또는 "* branch_name" + branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] + return branch_name in branches + return False + + +def remote_branch_exists(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트에 브랜치가 존재하는지 확인 + + Args: + branch_name: 확인할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 리모트에 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '-r', '--list', f'{remote}/{branch_name}'], check=False) + if success and stdout: + branches = [line.strip() for line in stdout.split('\n')] + return f'{remote}/{branch_name}' in branches + return False + + +def fetch_remote(remote: str = 'origin') -> bool: + """ + 리모트에서 최신 정보를 가져옵니다 (git fetch) + + Args: + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + print_step("🔄", f"리모트({remote}) 최신 정보 가져오는 중...") + success, _, stderr = run_git_command(['fetch', remote], check=False) + if not success: + print_warning(f"리모트 fetch 실패: {stderr}") + return success def create_branch(branch_name: str) -> bool: - """ - 현재 브랜치에서 새 브랜치 생성 - - Args: - branch_name: 생성할 브랜치명 - - Returns: - bool: 성공 여부 - """ - success, _, stderr = run_git_command(['branch', branch_name], check=False) - if not success: - print_error(f"브랜치 생성 실패: {stderr}") - return success + """ + 새 브랜치 생성 (현재 브랜치에서 분기) + + Args: + branch_name: 생성할 브랜치명 + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['branch', branch_name], check=False) + if not success: + print_error(f"브랜치 생성 실패: {stderr}") + return success + + +def create_branch_from_remote(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트 브랜치를 기반으로 로컬 tracking 브랜치 생성 + + Args: + branch_name: 생성할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command( + ['branch', '--track', branch_name, f'{remote}/{branch_name}'], + check=False + ) + if not success: + print_error(f"리모트 브랜치 기반 로컬 브랜치 생성 실패: {stderr}") + return success def get_worktree_list() -> Dict[str, str]: - """ - 현재 등록된 worktree 목록 반환 - - Returns: - Dict[str, str]: {worktree_path: branch_name} - """ - success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) - if not success: - return {} - - worktrees = {} - current_path = None - - for line in stdout.split('\n'): - if line.startswith('worktree '): - current_path = line.replace('worktree ', '') - elif line.startswith('branch '): - branch = line.replace('branch ', '').replace('refs/heads/', '') - if current_path: - worktrees[current_path] = branch - current_path = None - - return worktrees + """ + 현재 등록된 worktree 목록 반환 + + Returns: + Dict[str, str]: {worktree_path: branch_name} + """ + success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) + if not success: + return {} + + worktrees = {} + current_path = None + + for line in stdout.split('\n'): + if line.startswith('worktree '): + current_path = line.replace('worktree ', '') + elif line.startswith('branch '): + branch = line.replace('branch ', '').replace('refs/heads/', '') + if current_path: + worktrees[current_path] = branch + current_path = None + + return worktrees def prune_worktrees() -> bool: - """ - 유효하지 않은 worktree 정리 (git worktree prune) + """ + 유효하지 않은 worktree 정리 (git worktree prune) - Returns: - bool: 성공 여부 - """ - success, _, stderr = run_git_command(['worktree', 'prune'], check=False) - if not success: - print_warning(f"Worktree prune 실패: {stderr}") - return success + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['worktree', 'prune'], check=False) + if not success: + print_warning(f"Worktree prune 실패: {stderr}") + return success def is_worktree_exists(worktree_path: Path) -> bool: - """ - 특정 경로에 worktree가 이미 존재하는지 확인 - - Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. - prunable 상태의 worktree는 자동으로 정리합니다. - - Args: - worktree_path: 확인할 worktree 경로 - - Returns: - bool: worktree가 유효하게 존재하면 True - """ - # 먼저 prunable worktree 정리 - prune_worktrees() - - worktrees = get_worktree_list() - worktree_path_resolved = worktree_path.resolve() - - for path in worktrees.keys(): - if Path(path).resolve() == worktree_path_resolved: - # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 - if Path(path).exists(): - return True - else: - # 디렉토리가 없으면 다시 prune 실행 - print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") - prune_worktrees() - return False - - # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 - if worktree_path_resolved.exists(): - # .git 파일이 있는지 확인 (worktree의 특징) - git_file = worktree_path_resolved / '.git' - if git_file.exists(): - print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") - return True + """ + 특정 경로에 worktree가 이미 존재하는지 확인 - return False + Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. + prunable 상태의 worktree는 자동으로 정리합니다. + + Args: + worktree_path: 확인할 worktree 경로 + + Returns: + bool: worktree가 유효하게 존재하면 True + """ + # 먼저 prunable worktree 정리 + prune_worktrees() + + worktrees = get_worktree_list() + worktree_path_resolved = worktree_path.resolve() + + for path in worktrees.keys(): + if Path(path).resolve() == worktree_path_resolved: + # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 + if Path(path).exists(): + return True + else: + # 디렉토리가 없으면 다시 prune 실행 + print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") + prune_worktrees() + return False + + # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 + if worktree_path_resolved.exists(): + # .git 파일이 있는지 확인 (worktree의 특징) + git_file = worktree_path_resolved / '.git' + if git_file.exists(): + print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") + return True + + return False def create_worktree(branch_name: str, worktree_path: Path) -> Dict: - """ - Git worktree 생성 - - Args: - branch_name: 체크아웃할 브랜치명 - worktree_path: worktree를 생성할 경로 - - Returns: - Dict: { - 'success': bool, - 'path': str, - 'message': str, - 'is_existing': bool - } - """ - # 이미 존재하는지 확인 - if is_worktree_exists(worktree_path): - return { - 'success': True, - 'path': str(worktree_path.resolve()), - 'message': 'Worktree가 이미 존재합니다.', - 'is_existing': True - } - - # worktree 생성 - success, stdout, stderr = run_git_command( - ['worktree', 'add', str(worktree_path), branch_name], - check=False - ) - - if success: - return { - 'success': True, - 'path': str(worktree_path.resolve()), - 'message': 'Worktree 생성 완료!', - 'is_existing': False - } - else: - return { - 'success': False, - 'path': str(worktree_path.resolve()), - 'message': f'Worktree 생성 실패: {stderr}', - 'is_existing': False - } + """ + Git worktree 생성 + + Args: + branch_name: 체크아웃할 브랜치명 + worktree_path: worktree를 생성할 경로 + + Returns: + Dict: { + 'success': bool, + 'path': str, + 'message': str, + 'is_existing': bool + } + """ + # 이미 존재하는지 확인 + if is_worktree_exists(worktree_path): + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree가 이미 존재합니다.', + 'is_existing': True + } + + # worktree 생성 + success, stdout, stderr = run_git_command( + ['worktree', 'add', str(worktree_path), branch_name], + check=False + ) + + if success: + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree 생성 완료!', + 'is_existing': False + } + else: + return { + 'success': False, + 'path': str(worktree_path.resolve()), + 'message': f'Worktree 생성 실패: {stderr}', + 'is_existing': False + } # =================================================================== @@ -394,96 +468,96 @@ def create_worktree(branch_name: str, worktree_path: Path) -> Dict: # =================================================================== def normalize_branch_name(branch_name: str) -> str: - """ - 브랜치명을 폴더명으로 안전하게 변환 - - 특수문자 (#, /, \\\\, :, *, ?, ", <, >, |)를 _ 로 변환하고, - 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. - - Args: - branch_name: 원본 브랜치명 - - Returns: - str: 정규화된 폴더명 - - Example: - >>> normalize_branch_name("20260120_#163_Github_Projects") - "20260120_163_Github_Projects" - """ - # 특수문자를 _ 로 변환 - normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) - - # 연속된 _를 하나로 통합 - normalized = re.sub(r'_+', '_', normalized) - - # 앞뒤 _를 제거 - normalized = normalized.strip('_') - - return normalized + """ + 브랜치명을 폴더명으로 안전하게 변환 + + 특수문자 (#, /, \\, :, *, ?, ", <, >, |)를 _ 로 변환하고, + 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. + + Args: + branch_name: 원본 브랜치명 + + Returns: + str: 정규화된 폴더명 + + Example: + >>> normalize_branch_name("20260120_#163_Github_Projects") + "20260120_163_Github_Projects" + """ + # 특수문자를 _ 로 변환 + normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) + + # 연속된 _를 하나로 통합 + normalized = re.sub(r'_+', '_', normalized) + + # 앞뒤 _를 제거 + normalized = normalized.strip('_') + + return normalized def get_worktree_root() -> Path: - """ - Worktree 루트 경로 계산 - - 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 - - Returns: - Path: Worktree 루트 경로 - - Example: - 현재: /Users/.../project/RomRom-FE - 반환: /Users/.../project/RomRom-FE-Worktree - """ - git_root = get_git_root() - if not git_root: - raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") - - # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) - project_name = git_root.name - - # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 - worktree_root_name = f"{project_name}-Worktree" - worktree_root = git_root.parent / worktree_root_name - - return worktree_root + """ + Worktree 루트 경로 계산 + + 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + + Returns: + Path: Worktree 루트 경로 + + Example: + 현재: /Users/.../project/RomRom-FE + 반환: /Users/.../project/RomRom-FE-Worktree + """ + git_root = get_git_root() + if not git_root: + raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") + + # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) + project_name = git_root.name + + # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + worktree_root_name = f"{project_name}-Worktree" + worktree_root = git_root.parent / worktree_root_name + + return worktree_root def get_worktree_path(branch_name: str) -> Path: - """ - 특정 브랜치의 worktree 전체 경로 반환 - - Args: - branch_name: 브랜치명 (정규화 전) - - Returns: - Path: Worktree 경로 - - Example: - >>> get_worktree_path("20260120_#163_Github_Projects") - Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") - """ - worktree_root = get_worktree_root() - folder_name = normalize_branch_name(branch_name) - return worktree_root / folder_name + """ + 특정 브랜치의 worktree 전체 경로 반환 + + Args: + branch_name: 브랜치명 (정규화 전) + + Returns: + Path: Worktree 경로 + + Example: + >>> get_worktree_path("20260120_#163_Github_Projects") + Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") + """ + worktree_root = get_worktree_root() + folder_name = normalize_branch_name(branch_name) + return worktree_root / folder_name def ensure_directory(path: Path) -> bool: - """ - 디렉토리가 존재하지 않으면 생성 - - Args: - path: 생성할 디렉토리 경로 - - Returns: - bool: 성공 여부 - """ - try: - path.mkdir(parents=True, exist_ok=True) - return True - except Exception as e: - print_error(f"디렉토리 생성 실패: {e}") - return False + """ + 디렉토리가 존재하지 않으면 생성 + + Args: + path: 생성할 디렉토리 경로 + + Returns: + bool: 성공 여부 + """ + try: + path.mkdir(parents=True, exist_ok=True) + return True + except Exception as e: + print_error(f"디렉토리 생성 실패: {e}") + return False # =================================================================== @@ -491,131 +565,149 @@ def ensure_directory(path: Path) -> bool: # =================================================================== def main() -> int: - """ - 메인 워크플로우 - - Returns: - int: Exit code (0: 성공, 1: 실패) - """ - print_header() - - # 1. 브랜치명 받기 (Windows 환경 대응) - branch_name = get_branch_name() - - if not branch_name: - print_error("브랜치명이 제공되지 않았습니다.") - print() - print("사용법:") - if IS_WINDOWS: - print(" Windows 환경:") - print(" 방법 1: 환경 변수 사용") - print(f' $env:GIT_BRANCH_NAME = "브랜치명"') - print(f" python -X utf8 {sys.argv[0]}") - print() - print(" 방법 2: 인자로 전달") - print(f' python -X utf8 {sys.argv[0]} "브랜치명"') - else: - print(f" python {sys.argv[0]} ") - print() - print("예시:") - print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') - return 1 + """ + 메인 워크플로우 - print_step("📋", f"입력된 브랜치: {branch_name}") - - # 2. Git 저장소 확인 - if not is_git_repository(): - print_error("현재 디렉토리가 Git 저장소가 아닙니다.") - return 1 + Returns: + int: Exit code (0: 성공, 1: 실패) + """ + print_header() - # 2-1. Windows 긴 경로 지원 확인 및 활성화 - if IS_WINDOWS: - check_and_enable_longpaths() - print() + # 1. 브랜치명 받기 (Windows 환경 대응) + branch_name = get_branch_name() - # 3. 브랜치명 정규화 - folder_name = normalize_branch_name(branch_name) - print_step("📁", f"폴더명: {folder_name}") + if not branch_name: + print_error("브랜치명이 제공되지 않았습니다.") print() - - # 4. 브랜치 존재 확인 - print_step("🔍", "브랜치 확인 중...") - - if not branch_exists(branch_name): - print_warning("브랜치가 존재하지 않습니다.") - - current_branch = get_current_branch() - if current_branch: - print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") - else: - print_step("🔄", "새 브랜치 생성 중...") - - if not create_branch(branch_name): - print_error("브랜치 생성에 실패했습니다.") - return 1 - - print_success("브랜치 생성 완료!") + print("사용법:") + if IS_WINDOWS: + print(" Windows 환경:") + print(" 방법 1: 환경 변수 사용") + print(f' $env:GIT_BRANCH_NAME = "브랜치명"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 2: 파일 사용") + print(f' $env:BRANCH_NAME_FILE = "branch_name.txt"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 3: 인자로 전달 (한글 깨짐 가능)") + print(f' python {sys.argv[0]} "브랜치명"') else: - print_success("브랜치가 이미 존재합니다.") - + print(f" python {sys.argv[0]} ") print() - - # 5. Worktree 경로 계산 - try: - worktree_path = get_worktree_path(branch_name) - except RuntimeError as e: - print_error(str(e)) - return 1 - - print_step("📂", f"Worktree 경로: {worktree_path}") + print("예시:") + print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') + return 1 + + print_step("📋", f"입력된 브랜치: {branch_name}") + + # 2. Git 저장소 확인 + if not is_git_repository(): + print_error("현재 디렉토리가 Git 저장소가 아닙니다.") + return 1 + + # 2-1. Windows 긴 경로 지원 확인 및 활성화 + if IS_WINDOWS: + check_and_enable_longpaths() print() - - # 6. Worktree 존재 확인 - print_step("🔍", "Worktree 확인 중...") - - if is_worktree_exists(worktree_path): - print_info("Worktree가 이미 존재합니다.") - print() - print_step("📍", f"경로: {worktree_path.resolve()}") - return 0 - - # 7. Worktree 루트 디렉토리 생성 - worktree_root = get_worktree_root() - if not ensure_directory(worktree_root): + + # 3. 브랜치명 정규화 + folder_name = normalize_branch_name(branch_name) + print_step("📁", f"폴더명: {folder_name}") + print() + + # 4. 브랜치 존재 확인 (로컬 → 리모트 순서) + print_step("🔍", "브랜치 확인 중...") + + if branch_exists(branch_name): + print_success("로컬 브랜치가 이미 존재합니다.") + else: + print_warning("로컬 브랜치가 존재하지 않습니다.") + + # 리모트에서 최신 정보 가져오기 + fetch_remote() + + if remote_branch_exists(branch_name): + # 리모트에 브랜치가 있으면 tracking 브랜치로 생성 + print_step("🌐", f"리모트(origin/{branch_name})에서 브랜치를 가져옵니다...") + + if not create_branch_from_remote(branch_name): + print_error("리모트 브랜치 기반 로컬 브랜치 생성에 실패했습니다.") return 1 - - # 8. Worktree 생성 - print_step("🔄", "Worktree 생성 중...") - - result = create_worktree(branch_name, worktree_path) - - if result['success']: - if result['is_existing']: - print_info(result['message']) - else: - print_success(result['message']) - - print() - print_step("📍", f"경로: {result['path']}") - return 0 + + print_success(f"리모트 브랜치(origin/{branch_name})를 기반으로 로컬 브랜치 생성 완료!") else: - print_error(result['message']) + # 리모트에도 없으면 현재 브랜치에서 새로 생성 + current_branch = get_current_branch() + if current_branch: + print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") + else: + print_step("🔄", "새 브랜치 생성 중...") + + if not create_branch(branch_name): + print_error("브랜치 생성에 실패했습니다.") return 1 + print_success("새 브랜치 생성 완료!") + + print() + + # 5. Worktree 경로 계산 + try: + worktree_path = get_worktree_path(branch_name) + except RuntimeError as e: + print_error(str(e)) + return 1 + + print_step("📂", f"Worktree 경로: {worktree_path}") + print() + + # 6. Worktree 존재 확인 + print_step("🔍", "Worktree 확인 중...") + + if is_worktree_exists(worktree_path): + print_info("Worktree가 이미 존재합니다.") + print() + print_step("📍", f"경로: {worktree_path.resolve()}") + return 0 + + # 7. Worktree 루트 디렉토리 생성 + worktree_root = get_worktree_root() + if not ensure_directory(worktree_root): + return 1 + + # 8. Worktree 생성 + print_step("🔄", "Worktree 생성 중...") + + result = create_worktree(branch_name, worktree_path) + + if result['success']: + if result['is_existing']: + print_info(result['message']) + else: + print_success(result['message']) + + print() + print_step("📍", f"경로: {result['path']}") + return 0 + else: + print_error(result['message']) + return 1 + # =================================================================== # 엔트리 포인트 # =================================================================== if __name__ == "__main__": - try: - exit_code = main() - sys.exit(exit_code) - except KeyboardInterrupt: - print() - print_warning("사용자에 의해 중단되었습니다.") - sys.exit(130) - except Exception as e: - print() - print_error(f"예상치 못한 오류가 발생했습니다: {e}") - sys.exit(1) + try: + exit_code = main() + sys.exit(exit_code) + except KeyboardInterrupt: + print() + print_warning("사용자에 의해 중단되었습니다.") + sys.exit(130) + except Exception as e: + print() + print_error(f"예상치 못한 오류가 발생했습니다: {e}") + sys.exit(1) diff --git a/.cursor/commands/init-workflow.md b/.cursor/commands/init-worktree.md similarity index 75% rename from .cursor/commands/init-workflow.md rename to .cursor/commands/init-worktree.md index c9ff9d6..7e77d74 100644 --- a/.cursor/commands/init-workflow.md +++ b/.cursor/commands/init-worktree.md @@ -1,11 +1,11 @@ -# init-workflow +# init-worktree Git worktree를 자동으로 생성하는 커맨드입니다. 브랜치명을 입력받아 자동으로: -1. 브랜치명은 사용자 입력 그대로 사용 (예: `20260127_#16_...`) -2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) -3. 폴더명만 특수문자(`#` 포함)를 `_`로 변환하여 생성 (예: `20260127_16_...`) +1. 브랜치명에서 `#` 문자 제거 (Git 브랜치명으로 사용) +2. 브랜치가 없으면 리모트(origin) 확인 → 있으면 tracking 브랜치로 가져오기, 없으면 현재 브랜치에서 새로 생성 +3. 브랜치명의 특수문자를 `_`로 변환하여 폴더명 생성 4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) 5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) 6. 이미 존재하면 경로만 출력 @@ -13,7 +13,7 @@ Git worktree를 자동으로 생성하는 커맨드입니다. ## 사용법 ``` -/init-workflow +/init-worktree 20260120_#163_Github_Projects_에_대한_템플릿_개발_필요_및_관련_Sync_워크플로우_개발_필요 ``` @@ -21,7 +21,7 @@ Git worktree를 자동으로 생성하는 커맨드입니다. ## 실행 로직 1. 사용자 입력에서 두 번째 줄의 브랜치명 추출 -2. 브랜치명은 그대로 사용 (임시 Python 스크립트에 포함) +2. 브랜치명에서 `#` 문자 제거 3. 임시 Python 스크립트 파일 생성 (인코딩 문제 해결) 4. Python 스크립트 실행 (worktree 생성 + 설정 파일 복사) 5. 임시 파일 자동 삭제 @@ -37,22 +37,20 @@ Git worktree를 자동으로 생성하는 커맨드입니다. 브랜치명이 제공된 경우: 1. 프로젝트 루트로 이동 2. Git 긴 경로 지원 활성화: `git config --global core.longpaths true` (최초 1회만 실행) -3. 브랜치명은 사용자 입력 그대로 사용 (예: `20260116_#432_...` 그대로 유지) -4. 임시 Python 스크립트 파일 생성: - - 파일명: `init_worktree_temp_{timestamp}.py` - - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결) - - worktree 생성 로직 포함 -5. **Python 스크립트 실행** (Windows에서는 `-X utf8` 플래그 필수): +3. 임시 Python 스크립트 파일 생성: + - 파일명: `init_worktree_temp_{timestamp}.py` + - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결, `#` 문자 유지) + - worktree 생성 로직 포함 +4. **Python 스크립트 실행** (Windows에서는 `-X utf8` 플래그 필수): ```bash python -X utf8 init_worktree_temp_{timestamp}.py ``` -6. 임시 파일 삭제 -7. 결과 출력 -8. 에이전트가 `.gitignore` 분석 후 민감 파일 복사 +5. 임시 파일 삭제 +6. 결과 출력 +7. 에이전트가 `.gitignore` 분석 후 민감 파일 복사 **중요**: -- **브랜치명 처리**: 브랜치명은 사용자 입력 그대로 사용됩니다 (`#` 문자 포함) -- **폴더명 처리**: 폴더명만 `normalize_branch_name()` 함수로 특수문자(`#` 포함)를 `_`로 변환합니다 +- **브랜치명 처리**: 브랜치명은 `#` 문자를 포함하여 **원본 그대로** 전달됩니다. `#`은 **폴더명 생성 시에만** `_`로 변환됩니다. - **인코딩 문제 해결**: Python 스크립트 파일에 브랜치명을 직접 포함시켜 Windows PowerShell 인코딩 문제 회피 - **Windows UTF-8 모드**: Python 실행 시 `-X utf8` 플래그 사용 필수 - **설정 파일 자동 복사**: worktree 생성 후 에이전트가 동적으로 파일 복사 @@ -67,7 +65,7 @@ git config --global core.longpaths true # Python UTF-8 모드로 실행 (Windows 한글 인코딩 문제 해결) python -X utf8 init_worktree_temp.py -# 입력 브랜치명: 20260116_#432_UX_개선_및_페이지_디자인_수정 +# 브랜치명: 20260116_#432_UX_개선_및_페이지_디자인_수정 # → Git 브랜치: 20260116_#432_UX_개선_및_페이지_디자인_수정 (# 유지) # → 폴더명: 20260116_432_UX_개선_및_페이지_디자인_수정 (# → _ 변환) ``` @@ -83,7 +81,7 @@ import glob # 프로젝트 루트로 이동 os.chdir('프로젝트_루트_경로') -# 브랜치명 (사용자 입력 그대로) +# 브랜치명 (원본 그대로, # 유지) branch_name = '20260116_#432_UX_개선_및_페이지_디자인_수정' # worktree_manager 실행 @@ -96,18 +94,18 @@ exit_code = worktree_manager.main() # worktree 경로를 환경변수로 설정 (에이전트가 파일 복사에 사용) if exit_code == 0: - import subprocess - result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], + import subprocess + result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], capture_output=True, text=True, encoding='utf-8') - lines = result.stdout.split('\n') - worktree_path = None - for i, line in enumerate(lines): - if line.startswith(f'branch refs/heads/{branch_name}'): - worktree_path = lines[i-1].replace('worktree ', '') - break - - if worktree_path: - print(f'📍 WORKTREE_PATH={worktree_path}') + lines = result.stdout.split('\n') + worktree_path = None + for i, line in enumerate(lines): + if line.startswith(f'branch refs/heads/{branch_name}'): + worktree_path = lines[i-1].replace('worktree ', '') + break + + if worktree_path: + print(f'📍 WORKTREE_PATH={worktree_path}') sys.exit(exit_code) ``` diff --git a/.cursor/commands/issue.md b/.cursor/commands/issue.md new file mode 100644 index 0000000..aa13510 --- /dev/null +++ b/.cursor/commands/issue.md @@ -0,0 +1,280 @@ +# Issue Mode + +당신은 GitHub 이슈 작성 전문가입니다. 사용자의 대략적인 설명을 받아 **GitHub 이슈 템플릿에 맞는 제목과 본문을 자동으로 작성**합니다. + +## 절대 금지 사항 + +- ❌ 코드적인 내용 작성 금지 (구현 방법, 코드 예시 등 포함하지 않음) +- ❌ 긴급(🔥) 태그를 임의로 추가하지 않음 (사용자가 직접 "긴급"이라고 말한 경우에만) +- ❌ 담당자 내용을 임의로 채우지 않음 (템플릿 기본값 그대로 유지) + +## 사용자 입력 + +$ARGUMENTS + +--- + +## 동작 프로세스 + +### 1단계: 이슈 타입 자동 판단 + +사용자 입력을 분석하여 아래 4가지 타입 중 하나를 판단합니다: + +| 타입 | 판단 키워드/상황 | 템플릿 | +|------|----------------|--------| +| **버그** | 안 됨, 에러, 깨짐, 오류, 크래시, 안 됨, 문제, 장애, 실패, 로그 | `bug_report` | +| **기능 (추가/개선/요청)** | 추가, 만들어야, 새로, 구현, 필요, 개선, 수정, 변경, 요청, 제안 | `feature_request` | +| **디자인** | 디자인, UI, UX, 폰트, 색상, 레이아웃, 화면, 아이콘 | `design_request` | +| **QA/시험** | 테스트, QA, 시험, 검증, 확인 | `qa_request` | + +**기능 타입 세분류** (제목 이모지 결정): +- ⚙️`[기능추가]` : 완전히 새로운 기능 +- 🚀`[기능개선]` : 기존 기능 개선/수정 +- 🔧`[기능요청]` : 제안/요청 수준 + +### 2단계: 부족한 정보 질문 + +**반드시 질문하는 경우:** +- 타입 판단이 애매할 때 (버그인지 개선인지 등) +- 카테고리 태그를 특정할 수 없을 때 + +**질문하지 않는 경우 (알아서 판단):** +- 문맥상 명확한 것 (로그인 에러 → 버그) +- 환경 정보 등 선택 항목 (비워두면 됨) +- 담당자 (항상 기본값 유지) + +**질문 스타일:** 짧고 직관적으로, 가능하면 선택지 제공 + +### 3단계: 이슈 파일 저장 + +판단된 타입에 맞는 템플릿을 사용하여 **제목 + 본문**을 `.issue/` 폴더에 마크다운 파일로 저장합니다. + +### 이슈 파일 생성 로직 + +1. **.issue 폴더 확인** + - `.issue/` 폴더가 없으면 자동 생성 + +2. **파일명 생성** + - 형식: `#[YYYYMMDD]_[순번]_[이슈타입]_[간단한설명].md` + - `#` 접두사: 파일 정렬 및 이슈 파일 식별용 + - 날짜: 현재 날짜 (YYYYMMDD 형식) + - 순번: 같은 날짜 내 `.issue/` 폴더의 기존 파일 수를 확인하여 3자리 자동 증가 (001, 002, ...) + - 이슈타입: `버그`, `기능추가`, `기능개선`, `기능요청`, `디자인`, `시험요청` + - 설명: 한글/영문, 언더스코어로 단어 구분 + - 특수문자 제거 및 안전한 파일명으로 변환 + - 예시: `#20260214_001_버그_채팅방_중복생성_오류.md`, `#20260214_002_기능추가_물품삭제_확인_다이얼로그.md` + +3. **파일 내용** + - 첫 줄: 제목 그대로 (마크다운 `#` 없이, GitHub 이슈 제목에 바로 복사-붙여넣기 가능) + - 빈 줄 + - 나머지: 본문 (GitHub 이슈 본문으로 복사용) + +4. **파일 저장** + - `.issue/` 폴더에 직접 저장 + - Git 추적 안 됨 (`.gitignore`에 `/.issue` 등록) + +### 출력 + +1. 저장된 파일 경로 표시 +2. 이슈 내용 요약 표시 +3. "GitHub에 이슈를 생성하려면 위 제목과 본문을 복사해서 붙여넣으세요" 안내 + +--- + +## 제목 형식 + +기존 이슈 패턴을 준수합니다: + +``` +❗[버그][카테고리] 설명 +⚙️[기능추가][카테고리] 설명 +🚀[기능개선][카테고리] 설명 +🔧[기능요청][카테고리] 설명 +🎨[디자인][카테고리] 설명 +🔍[시험요청][카테고리] 설명 +``` + +- `[카테고리]`는 관련 영역을 넣음 (예: `[로그인]`, `[Flutter]`, `[CICD]`, `[마법사]`, `[CustomCommand]` 등) +- 카테고리는 여러 개 가능 (예: `[버그][Flutter][빌드]`) +- 사용자가 "긴급"이라고 직접 말한 경우에만 `🔥[긴급]` 추가 + +--- + +## 템플릿별 출력 형식 + +### 버그 이슈 (bug_report) + +```markdown +## 제목 +❗[버그][카테고리] 설명 + +## 본문 + +🗒️ 설명 +--- + +- {사용자 입력 기반으로 버그 현상 설명} + +🔄 재현 방법 +--- + +1. {재현 단계 1} +2. {재현 단계 2} +3. {결과 확인} + +📸 참고 자료 +--- + +{사용자가 로그나 스크린샷을 제공한 경우 여기에 정리, 없으면 비워둠} + +✅ 예상 동작 +--- + +- {정상적으로 동작해야 하는 모습} + +⚙️ 환경 정보 +--- + +- **OS**: +- **브라우저**: +- **기기**: + +🙋‍♂️ 담당자 +--- + +- **백엔드**: 이름 +- **프론트엔드**: 이름 +- **디자인**: 이름 +``` + +### 기능 요청/추가/개선 이슈 (feature_request) + +```markdown +## 제목 +⚙️/🚀/🔧[기능추가/개선/요청][카테고리] 설명 + +## 본문 + +📝 현재 문제점 +--- + +- {현재 어떤 부분이 부족하거나 필요한지} + +🛠️ 해결 방안 / 제안 기능 +--- + +- {제안하는 기능이나 해결 방안} + +🙋‍♂️ 담당자 +--- + +- 백엔드: 이름 +- 프론트엔드: 이름 +- 디자인: 이름 +``` + +**참고**: 기능추가/기능개선인 경우 필요하다 판단되면 `⚙️ 작업 내용` 섹션을 추가할 수 있음 (코드 내용이 아닌 작업 항목 수준으로): + +```markdown +⚙️ 작업 내용 +--- + +- {작업 항목 1} +- {작업 항목 2} +``` + +### 디자인 요청 이슈 (design_request) + +```markdown +## 제목 +🎨[디자인][카테고리] 설명 + +## 본문 + +🖌️ 요청 내용 +--- + +- {디자인 요청 내용} + +🎯 기대 결과 +--- + +- {디자인 적용 후 예상 결과} + +📋 참고 자료 +--- + +{참고 링크, 이미지 등} + +💡 추가 요청 사항 +--- + +- {추가 고려 사항} + +🙋‍♂️ 담당자 +--- + +- 백엔드: 이름 +- 프론트엔드: 이름 +- 디자인: 이름 +``` + +### QA/시험 이슈 (qa_request) + +```markdown +## 제목 +🔍[시험요청][카테고리] 설명 + +## 본문 + +🔗 ISSUE 정보 +--- + +- {관련 이슈 번호/링크} + +🔗 PR 정보 +--- + +- {관련 PR 번호/링크} + +🧩 시험 대상 +--- + +- {테스트할 기능/수정사항} + +📋 테스트 시나리오 +--- + +1. {테스트 케이스 1} +2. {테스트 케이스 2} +3. {테스트 케이스 3} + +⚙️ 테스트 환경 +--- + +- **프로젝트 Version**: +- **OS**: +- **브라우저**: +- **기기**: + +🙋‍♂️ 담당자 +--- + +- **시험담당**: 이름 +``` + +--- + +## 작성 원칙 + +1. **간결하게**: 불필요한 설명 없이 핵심만 작성 +2. **코드 없이**: 기능/현상 중심으로만 작성, 구현 방법이나 코드 언급 금지 +3. **템플릿 준수**: 위 템플릿 구조를 정확히 따름 +4. **적절한 추론**: 사용자가 대충 말해도 문맥에서 파악 가능한 건 알아서 채움 +5. **필요하면 질문**: 정말 모르겠는 것만 짧게 질문 +6. **담당자 기본값 유지**: 담당자는 항상 "이름"으로 유지 +7. **보강 가능**: 필요하다 판단되면 내용을 적절히 추가해도 됨 + +--- + +**목표**: "사용자가 대충 설명해도, 깔끔한 GitHub 이슈를 바로 만들 수 있게" diff --git a/.cursor/scripts/README.md b/.cursor/scripts/README.md index 9e558be..4d46f2f 100644 --- a/.cursor/scripts/README.md +++ b/.cursor/scripts/README.md @@ -39,7 +39,7 @@ Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. **Cursor Command로 실행 (권장):** ``` -/init-workflow +/init-worktree 20260120_#163_Github_Projects_에_대한_템플릿_개발_필요 ``` diff --git a/.cursor/scripts/worktree_manager.py b/.cursor/scripts/worktree_manager.py index fe2dbe0..4ff09c9 100644 --- a/.cursor/scripts/worktree_manager.py +++ b/.cursor/scripts/worktree_manager.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -Git Worktree Manager v1.0.4 +Git Worktree Manager v1.1.0 Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. -브랜치가 없으면 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. +브랜치가 없으면 리모트(origin) 확인 후 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. 사용법: macOS/Linux: @@ -32,21 +32,21 @@ # Windows 인코딩 문제 해결 - stdout/stderr를 UTF-8로 래핑 if platform.system() == 'Windows': - try: - # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 - if hasattr(sys.stdout, 'buffer'): - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - if hasattr(sys.stderr, 'buffer'): - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') - except Exception: - pass # 래핑 실패 시 무시 + try: + # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 + if hasattr(sys.stdout, 'buffer'): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + if hasattr(sys.stderr, 'buffer'): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + except Exception: + pass # 래핑 실패 시 무시 # =================================================================== # 상수 정의 # =================================================================== -VERSION = "1.0.4" +VERSION = "1.1.0" # Windows 환경 감지 IS_WINDOWS = platform.system() == 'Windows' @@ -64,108 +64,108 @@ # =================================================================== def get_branch_name() -> str: - """ - 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) - - Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 - 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. - - Returns: - str: 브랜치명 (비어있을 수 있음) - """ - if IS_WINDOWS: - # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) - # Windows에서 환경 변수는 시스템 기본 인코딩을 사용하므로 UTF-8로 디코딩 시도 - branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') - if branch_name_raw: - try: - # 환경 변수가 이미 올바른 인코딩인 경우 - branch_name = branch_name_raw.strip() - # 한글이 깨져있는지 확인 (깨진 경우 복구 시도) - if '\xef\xbf\xbd' in branch_name.encode('utf-8', errors='replace').decode('utf-8', errors='replace'): - # 깨진 경우, 시스템 인코딩으로 디코딩 후 UTF-8로 재인코딩 시도 - import locale - sys_encoding = locale.getpreferredencoding() - branch_name = branch_name_raw.encode(sys_encoding, errors='replace').decode('utf-8', errors='replace').strip() - else: - branch_name = branch_name.strip() - if branch_name: - return branch_name - except Exception: - # 인코딩 변환 실패 시 원본 사용 - branch_name = branch_name_raw.strip() - if branch_name: - return branch_name - - # 방법 2: 임시 파일에서 읽기 (init-workflow에서 파일 생성 후 전달) - temp_file = os.environ.get('BRANCH_NAME_FILE', '') - if temp_file and os.path.exists(temp_file): - try: - # 여러 인코딩 시도: UTF-8, UTF-8 with BOM, 시스템 기본 인코딩 - encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] - branch_name = None - for encoding in encodings: - try: - with open(temp_file, 'r', encoding=encoding) as f: - branch_name = f.read().strip() - if branch_name and not any(ord(c) > 0xFFFF for c in branch_name if ord(c) > 0x7F): - # 한글이 제대로 읽혔는지 확인 (깨진 문자가 없는지) - break - except (UnicodeDecodeError, UnicodeError): - continue - - if branch_name: - return branch_name - except Exception as e: - print_warning(f"브랜치명 파일 읽기 실패: {e}") - - # 방법 3: stdin에서 읽기 시도 (파이프 입력인 경우) - if not sys.stdin.isatty(): - try: - branch_name = sys.stdin.read().strip() - if branch_name: - return branch_name - except Exception: - pass - - # 기본: sys.argv에서 받기 (macOS/Linux 또는 Windows에서도 인자로 전달된 경우) - if len(sys.argv) >= 2: - return sys.argv[1].strip() - - return '' + """ + 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) + + Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 + 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. + + Returns: + str: 브랜치명 (비어있을 수 있음) + """ + if IS_WINDOWS: + # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) + # Windows에서 환경 변수는 시스템 기본 인코딩을 사용하므로 UTF-8로 디코딩 시도 + branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') + if branch_name_raw: + try: + # 환경 변수가 이미 올바른 인코딩인 경우 + branch_name = branch_name_raw.strip() + # 한글이 깨져있는지 확인 (깨진 경우 복구 시도) + if '\xef\xbf\xbd' in branch_name.encode('utf-8', errors='replace').decode('utf-8', errors='replace'): + # 깨진 경우, 시스템 인코딩으로 디코딩 후 UTF-8로 재인코딩 시도 + import locale + sys_encoding = locale.getpreferredencoding() + branch_name = branch_name_raw.encode(sys_encoding, errors='replace').decode('utf-8', errors='replace').strip() + else: + branch_name = branch_name.strip() + if branch_name: + return branch_name + except Exception: + # 인코딩 변환 실패 시 원본 사용 + branch_name = branch_name_raw.strip() + if branch_name: + return branch_name + + # 방법 2: 임시 파일에서 읽기 (init-worktree에서 파일 생성 후 전달) + temp_file = os.environ.get('BRANCH_NAME_FILE', '') + if temp_file and os.path.exists(temp_file): + try: + # 여러 인코딩 시도: UTF-8, UTF-8 with BOM, 시스템 기본 인코딩 + encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] + branch_name = None + for encoding in encodings: + try: + with open(temp_file, 'r', encoding=encoding) as f: + branch_name = f.read().strip() + if branch_name and not any(ord(c) > 0xFFFF for c in branch_name if ord(c) > 0x7F): + # 한글이 제대로 읽혔는지 확인 (깨진 문자가 없는지) + break + except (UnicodeDecodeError, UnicodeError): + continue + + if branch_name: + return branch_name + except Exception as e: + print_warning(f"브랜치명 파일 읽기 실패: {e}") + + # 방법 3: stdin에서 읽기 시도 (파이프 입력인 경우) + if not sys.stdin.isatty(): + try: + branch_name = sys.stdin.read().strip() + if branch_name: + return branch_name + except Exception: + pass + + # 기본: sys.argv에서 받기 (macOS/Linux 또는 Windows에서도 인자로 전달된 경우) + if len(sys.argv) >= 2: + return sys.argv[1].strip() + + return '' def print_header(): - """헤더 출력""" - print("━" * 60) - print(f"🌿 Git Worktree Manager v{VERSION}") - print("━" * 60) - print() + """헤더 출력""" + print("━" * 60) + print(f"🌿 Git Worktree Manager v{VERSION}") + print("━" * 60) + print() def print_step(emoji: str, message: str): - """단계별 메시지 출력""" - print(f"{emoji} {message}") + """단계별 메시지 출력""" + print(f"{emoji} {message}") def print_error(message: str): - """에러 메시지 출력""" - print(f"❌ 에러: {message}", file=sys.stderr) + """에러 메시지 출력""" + print(f"❌ 에러: {message}", file=sys.stderr) def print_success(message: str): - """성공 메시지 출력""" - print(f"✅ {message}") + """성공 메시지 출력""" + print(f"✅ {message}") def print_info(message: str): - """정보 메시지 출력""" - print(f"ℹ️ {message}") + """정보 메시지 출력""" + print(f"ℹ️ {message}") def print_warning(message: str): - """경고 메시지 출력""" - print(f"⚠️ {message}") + """경고 메시지 출력""" + print(f"⚠️ {message}") # =================================================================== @@ -173,239 +173,294 @@ def print_warning(message: str): # =================================================================== def run_git_command(args: list, check: bool = True) -> Tuple[bool, str, str]: - """ - Git 명령어 실행 - - Args: - args: Git 명령어 인자 리스트 (예: ['branch', '--list']) - check: 에러 발생 시 예외를 발생시킬지 여부 - - Returns: - Tuple[bool, str, str]: (성공 여부, stdout, stderr) - """ - try: - result = subprocess.run( - ['git'] + args, - capture_output=True, - text=True, - encoding='utf-8', - check=check - ) - return True, result.stdout.strip(), result.stderr.strip() - except subprocess.CalledProcessError as e: - return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" - except Exception as e: - return False, "", str(e) + """ + Git 명령어 실행 + + Args: + args: Git 명령어 인자 리스트 (예: ['branch', '--list']) + check: 에러 발생 시 예외를 발생시킬지 여부 + + Returns: + Tuple[bool, str, str]: (성공 여부, stdout, stderr) + """ + try: + result = subprocess.run( + ['git'] + args, + capture_output=True, + text=True, + encoding='utf-8', + check=check + ) + return True, result.stdout.strip(), result.stderr.strip() + except subprocess.CalledProcessError as e: + return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" + except Exception as e: + return False, "", str(e) def check_and_enable_longpaths() -> bool: - """ - Windows에서 Git 긴 경로 지원 확인 및 활성화 - - Returns: - bool: 긴 경로 지원이 활성화되어 있으면 True - """ - if not IS_WINDOWS: - return True - - # 현재 설정 확인 - success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) - if success and stdout.strip().lower() == 'true': - return True - - # 긴 경로 지원 활성화 - print_info("Windows 긴 경로 지원을 활성화합니다...") - success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) - if success: - print_success("긴 경로 지원이 활성화되었습니다.") - return True - else: - print_warning(f"긴 경로 지원 활성화 실패: {stderr}") - print_warning("수동으로 실행하세요: git config --global core.longpaths true") - return False + """ + Windows에서 Git 긴 경로 지원 확인 및 활성화 + + Returns: + bool: 긴 경로 지원이 활성화되어 있으면 True + """ + if not IS_WINDOWS: + return True + + # 현재 설정 확인 + success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) + if success and stdout.strip().lower() == 'true': + return True + + # 긴 경로 지원 활성화 + print_info("Windows 긴 경로 지원을 활성화합니다...") + success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) + if success: + print_success("긴 경로 지원이 활성화되었습니다.") + return True + else: + print_warning(f"긴 경로 지원 활성화 실패: {stderr}") + print_warning("수동으로 실행하세요: git config --global core.longpaths true") + return False def is_git_repository() -> bool: - """현재 디렉토리가 Git 저장소인지 확인""" - success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) - return success + """현재 디렉토리가 Git 저장소인지 확인""" + success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) + return success def get_git_root() -> Optional[Path]: - """Git 저장소 루트 경로 반환""" - success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) - if success and stdout: - return Path(stdout) - return None + """Git 저장소 루트 경로 반환""" + success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) + if success and stdout: + return Path(stdout) + return None def get_current_branch() -> Optional[str]: - """현재 체크아웃된 브랜치명 반환""" - success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) - if success and stdout: - return stdout - return None + """현재 체크아웃된 브랜치명 반환""" + success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) + if success and stdout: + return stdout + return None def branch_exists(branch_name: str) -> bool: - """ - 브랜치 존재 여부 확인 - - Args: - branch_name: 확인할 브랜치명 - - Returns: - bool: 브랜치가 존재하면 True - """ - success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) - if success and stdout: - # 출력 형식: " branch_name" 또는 "* branch_name" - branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] - return branch_name in branches - return False + """ + 로컬 브랜치 존재 여부 확인 + + Args: + branch_name: 확인할 브랜치명 + + Returns: + bool: 로컬 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) + if success and stdout: + # 출력 형식: " branch_name" 또는 "* branch_name" + branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] + return branch_name in branches + return False + + +def remote_branch_exists(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트에 브랜치가 존재하는지 확인 + + Args: + branch_name: 확인할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 리모트에 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '-r', '--list', f'{remote}/{branch_name}'], check=False) + if success and stdout: + branches = [line.strip() for line in stdout.split('\n')] + return f'{remote}/{branch_name}' in branches + return False + + +def fetch_remote(remote: str = 'origin') -> bool: + """ + 리모트에서 최신 정보를 가져옵니다 (git fetch) + + Args: + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + print_step("🔄", f"리모트({remote}) 최신 정보 가져오는 중...") + success, _, stderr = run_git_command(['fetch', remote], check=False) + if not success: + print_warning(f"리모트 fetch 실패: {stderr}") + return success def create_branch(branch_name: str) -> bool: - """ - 현재 브랜치에서 새 브랜치 생성 - - Args: - branch_name: 생성할 브랜치명 - - Returns: - bool: 성공 여부 - """ - success, _, stderr = run_git_command(['branch', branch_name], check=False) - if not success: - print_error(f"브랜치 생성 실패: {stderr}") - return success + """ + 새 브랜치 생성 (현재 브랜치에서 분기) + + Args: + branch_name: 생성할 브랜치명 + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['branch', branch_name], check=False) + if not success: + print_error(f"브랜치 생성 실패: {stderr}") + return success + + +def create_branch_from_remote(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트 브랜치를 기반으로 로컬 tracking 브랜치 생성 + + Args: + branch_name: 생성할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command( + ['branch', '--track', branch_name, f'{remote}/{branch_name}'], + check=False + ) + if not success: + print_error(f"리모트 브랜치 기반 로컬 브랜치 생성 실패: {stderr}") + return success def get_worktree_list() -> Dict[str, str]: - """ - 현재 등록된 worktree 목록 반환 - - Returns: - Dict[str, str]: {worktree_path: branch_name} - """ - success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) - if not success: - return {} - - worktrees = {} - current_path = None - - for line in stdout.split('\n'): - if line.startswith('worktree '): - current_path = line.replace('worktree ', '') - elif line.startswith('branch '): - branch = line.replace('branch ', '').replace('refs/heads/', '') - if current_path: - worktrees[current_path] = branch - current_path = None - - return worktrees + """ + 현재 등록된 worktree 목록 반환 + + Returns: + Dict[str, str]: {worktree_path: branch_name} + """ + success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) + if not success: + return {} + + worktrees = {} + current_path = None + + for line in stdout.split('\n'): + if line.startswith('worktree '): + current_path = line.replace('worktree ', '') + elif line.startswith('branch '): + branch = line.replace('branch ', '').replace('refs/heads/', '') + if current_path: + worktrees[current_path] = branch + current_path = None + + return worktrees def prune_worktrees() -> bool: - """ - 유효하지 않은 worktree 정리 (git worktree prune) + """ + 유효하지 않은 worktree 정리 (git worktree prune) - Returns: - bool: 성공 여부 - """ - success, _, stderr = run_git_command(['worktree', 'prune'], check=False) - if not success: - print_warning(f"Worktree prune 실패: {stderr}") - return success + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['worktree', 'prune'], check=False) + if not success: + print_warning(f"Worktree prune 실패: {stderr}") + return success def is_worktree_exists(worktree_path: Path) -> bool: - """ - 특정 경로에 worktree가 이미 존재하는지 확인 - - Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. - prunable 상태의 worktree는 자동으로 정리합니다. - - Args: - worktree_path: 확인할 worktree 경로 - - Returns: - bool: worktree가 유효하게 존재하면 True - """ - # 먼저 prunable worktree 정리 - prune_worktrees() - - worktrees = get_worktree_list() - worktree_path_resolved = worktree_path.resolve() - - for path in worktrees.keys(): - if Path(path).resolve() == worktree_path_resolved: - # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 - if Path(path).exists(): - return True - else: - # 디렉토리가 없으면 다시 prune 실행 - print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") - prune_worktrees() - return False - - # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 - if worktree_path_resolved.exists(): - # .git 파일이 있는지 확인 (worktree의 특징) - git_file = worktree_path_resolved / '.git' - if git_file.exists(): - print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") - return True + """ + 특정 경로에 worktree가 이미 존재하는지 확인 - return False + Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. + prunable 상태의 worktree는 자동으로 정리합니다. + + Args: + worktree_path: 확인할 worktree 경로 + + Returns: + bool: worktree가 유효하게 존재하면 True + """ + # 먼저 prunable worktree 정리 + prune_worktrees() + + worktrees = get_worktree_list() + worktree_path_resolved = worktree_path.resolve() + + for path in worktrees.keys(): + if Path(path).resolve() == worktree_path_resolved: + # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 + if Path(path).exists(): + return True + else: + # 디렉토리가 없으면 다시 prune 실행 + print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") + prune_worktrees() + return False + + # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 + if worktree_path_resolved.exists(): + # .git 파일이 있는지 확인 (worktree의 특징) + git_file = worktree_path_resolved / '.git' + if git_file.exists(): + print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") + return True + + return False def create_worktree(branch_name: str, worktree_path: Path) -> Dict: - """ - Git worktree 생성 - - Args: - branch_name: 체크아웃할 브랜치명 - worktree_path: worktree를 생성할 경로 - - Returns: - Dict: { - 'success': bool, - 'path': str, - 'message': str, - 'is_existing': bool - } - """ - # 이미 존재하는지 확인 - if is_worktree_exists(worktree_path): - return { - 'success': True, - 'path': str(worktree_path.resolve()), - 'message': 'Worktree가 이미 존재합니다.', - 'is_existing': True - } - - # worktree 생성 - success, stdout, stderr = run_git_command( - ['worktree', 'add', str(worktree_path), branch_name], - check=False - ) - - if success: - return { - 'success': True, - 'path': str(worktree_path.resolve()), - 'message': 'Worktree 생성 완료!', - 'is_existing': False - } - else: - return { - 'success': False, - 'path': str(worktree_path.resolve()), - 'message': f'Worktree 생성 실패: {stderr}', - 'is_existing': False - } + """ + Git worktree 생성 + + Args: + branch_name: 체크아웃할 브랜치명 + worktree_path: worktree를 생성할 경로 + + Returns: + Dict: { + 'success': bool, + 'path': str, + 'message': str, + 'is_existing': bool + } + """ + # 이미 존재하는지 확인 + if is_worktree_exists(worktree_path): + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree가 이미 존재합니다.', + 'is_existing': True + } + + # worktree 생성 + success, stdout, stderr = run_git_command( + ['worktree', 'add', str(worktree_path), branch_name], + check=False + ) + + if success: + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree 생성 완료!', + 'is_existing': False + } + else: + return { + 'success': False, + 'path': str(worktree_path.resolve()), + 'message': f'Worktree 생성 실패: {stderr}', + 'is_existing': False + } # =================================================================== @@ -413,96 +468,96 @@ def create_worktree(branch_name: str, worktree_path: Path) -> Dict: # =================================================================== def normalize_branch_name(branch_name: str) -> str: - """ - 브랜치명을 폴더명으로 안전하게 변환 - - 특수문자 (#, /, \\, :, *, ?, ", <, >, |)를 _ 로 변환하고, - 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. - - Args: - branch_name: 원본 브랜치명 - - Returns: - str: 정규화된 폴더명 - - Example: - >>> normalize_branch_name("20260120_#163_Github_Projects") - "20260120_163_Github_Projects" - """ - # 특수문자를 _ 로 변환 - normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) - - # 연속된 _를 하나로 통합 - normalized = re.sub(r'_+', '_', normalized) - - # 앞뒤 _를 제거 - normalized = normalized.strip('_') - - return normalized + """ + 브랜치명을 폴더명으로 안전하게 변환 + + 특수문자 (#, /, \\, :, *, ?, ", <, >, |)를 _ 로 변환하고, + 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. + + Args: + branch_name: 원본 브랜치명 + + Returns: + str: 정규화된 폴더명 + + Example: + >>> normalize_branch_name("20260120_#163_Github_Projects") + "20260120_163_Github_Projects" + """ + # 특수문자를 _ 로 변환 + normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) + + # 연속된 _를 하나로 통합 + normalized = re.sub(r'_+', '_', normalized) + + # 앞뒤 _를 제거 + normalized = normalized.strip('_') + + return normalized def get_worktree_root() -> Path: - """ - Worktree 루트 경로 계산 - - 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 - - Returns: - Path: Worktree 루트 경로 - - Example: - 현재: /Users/.../project/RomRom-FE - 반환: /Users/.../project/RomRom-FE-Worktree - """ - git_root = get_git_root() - if not git_root: - raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") - - # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) - project_name = git_root.name - - # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 - worktree_root_name = f"{project_name}-Worktree" - worktree_root = git_root.parent / worktree_root_name - - return worktree_root + """ + Worktree 루트 경로 계산 + + 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + + Returns: + Path: Worktree 루트 경로 + + Example: + 현재: /Users/.../project/RomRom-FE + 반환: /Users/.../project/RomRom-FE-Worktree + """ + git_root = get_git_root() + if not git_root: + raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") + + # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) + project_name = git_root.name + + # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + worktree_root_name = f"{project_name}-Worktree" + worktree_root = git_root.parent / worktree_root_name + + return worktree_root def get_worktree_path(branch_name: str) -> Path: - """ - 특정 브랜치의 worktree 전체 경로 반환 - - Args: - branch_name: 브랜치명 (정규화 전) - - Returns: - Path: Worktree 경로 - - Example: - >>> get_worktree_path("20260120_#163_Github_Projects") - Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") - """ - worktree_root = get_worktree_root() - folder_name = normalize_branch_name(branch_name) - return worktree_root / folder_name + """ + 특정 브랜치의 worktree 전체 경로 반환 + + Args: + branch_name: 브랜치명 (정규화 전) + + Returns: + Path: Worktree 경로 + + Example: + >>> get_worktree_path("20260120_#163_Github_Projects") + Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") + """ + worktree_root = get_worktree_root() + folder_name = normalize_branch_name(branch_name) + return worktree_root / folder_name def ensure_directory(path: Path) -> bool: - """ - 디렉토리가 존재하지 않으면 생성 - - Args: - path: 생성할 디렉토리 경로 - - Returns: - bool: 성공 여부 - """ - try: - path.mkdir(parents=True, exist_ok=True) - return True - except Exception as e: - print_error(f"디렉토리 생성 실패: {e}") - return False + """ + 디렉토리가 존재하지 않으면 생성 + + Args: + path: 생성할 디렉토리 경로 + + Returns: + bool: 성공 여부 + """ + try: + path.mkdir(parents=True, exist_ok=True) + return True + except Exception as e: + print_error(f"디렉토리 생성 실패: {e}") + return False # =================================================================== @@ -510,135 +565,149 @@ def ensure_directory(path: Path) -> bool: # =================================================================== def main() -> int: - """ - 메인 워크플로우 - - Returns: - int: Exit code (0: 성공, 1: 실패) - """ - print_header() - - # 1. 브랜치명 받기 (Windows 환경 대응) - branch_name = get_branch_name() - - if not branch_name: - print_error("브랜치명이 제공되지 않았습니다.") - print() - print("사용법:") - if IS_WINDOWS: - print(" Windows 환경:") - print(" 방법 1: 환경 변수 사용") - print(f' $env:GIT_BRANCH_NAME = "브랜치명"') - print(f" python {sys.argv[0]}") - print() - print(" 방법 2: 파일 사용") - print(f' $env:BRANCH_NAME_FILE = "branch_name.txt"') - print(f" python {sys.argv[0]}") - print() - print(" 방법 3: 인자로 전달 (한글 깨짐 가능)") - print(f' python {sys.argv[0]} "브랜치명"') - else: - print(f" python {sys.argv[0]} ") - print() - print("예시:") - print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') - return 1 - - print_step("📋", f"입력된 브랜치: {branch_name}") - - # 2. Git 저장소 확인 - if not is_git_repository(): - print_error("현재 디렉토리가 Git 저장소가 아닙니다.") - return 1 - - # 2-1. Windows 긴 경로 지원 확인 및 활성화 - if IS_WINDOWS: - check_and_enable_longpaths() - print() - - # 3. 브랜치명 정규화 - folder_name = normalize_branch_name(branch_name) - print_step("📁", f"폴더명: {folder_name}") + """ + 메인 워크플로우 + + Returns: + int: Exit code (0: 성공, 1: 실패) + """ + print_header() + + # 1. 브랜치명 받기 (Windows 환경 대응) + branch_name = get_branch_name() + + if not branch_name: + print_error("브랜치명이 제공되지 않았습니다.") print() - - # 4. 브랜치 존재 확인 - print_step("🔍", "브랜치 확인 중...") - - if not branch_exists(branch_name): - print_warning("브랜치가 존재하지 않습니다.") - - current_branch = get_current_branch() - if current_branch: - print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") - else: - print_step("🔄", "새 브랜치 생성 중...") - - if not create_branch(branch_name): - print_error("브랜치 생성에 실패했습니다.") - return 1 - - print_success("브랜치 생성 완료!") + print("사용법:") + if IS_WINDOWS: + print(" Windows 환경:") + print(" 방법 1: 환경 변수 사용") + print(f' $env:GIT_BRANCH_NAME = "브랜치명"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 2: 파일 사용") + print(f' $env:BRANCH_NAME_FILE = "branch_name.txt"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 3: 인자로 전달 (한글 깨짐 가능)") + print(f' python {sys.argv[0]} "브랜치명"') else: - print_success("브랜치가 이미 존재합니다.") - + print(f" python {sys.argv[0]} ") print() - - # 5. Worktree 경로 계산 - try: - worktree_path = get_worktree_path(branch_name) - except RuntimeError as e: - print_error(str(e)) - return 1 - - print_step("📂", f"Worktree 경로: {worktree_path}") + print("예시:") + print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') + return 1 + + print_step("📋", f"입력된 브랜치: {branch_name}") + + # 2. Git 저장소 확인 + if not is_git_repository(): + print_error("현재 디렉토리가 Git 저장소가 아닙니다.") + return 1 + + # 2-1. Windows 긴 경로 지원 확인 및 활성화 + if IS_WINDOWS: + check_and_enable_longpaths() print() - - # 6. Worktree 존재 확인 - print_step("🔍", "Worktree 확인 중...") - - if is_worktree_exists(worktree_path): - print_info("Worktree가 이미 존재합니다.") - print() - print_step("📍", f"경로: {worktree_path.resolve()}") - return 0 - - # 7. Worktree 루트 디렉토리 생성 - worktree_root = get_worktree_root() - if not ensure_directory(worktree_root): + + # 3. 브랜치명 정규화 + folder_name = normalize_branch_name(branch_name) + print_step("📁", f"폴더명: {folder_name}") + print() + + # 4. 브랜치 존재 확인 (로컬 → 리모트 순서) + print_step("🔍", "브랜치 확인 중...") + + if branch_exists(branch_name): + print_success("로컬 브랜치가 이미 존재합니다.") + else: + print_warning("로컬 브랜치가 존재하지 않습니다.") + + # 리모트에서 최신 정보 가져오기 + fetch_remote() + + if remote_branch_exists(branch_name): + # 리모트에 브랜치가 있으면 tracking 브랜치로 생성 + print_step("🌐", f"리모트(origin/{branch_name})에서 브랜치를 가져옵니다...") + + if not create_branch_from_remote(branch_name): + print_error("리모트 브랜치 기반 로컬 브랜치 생성에 실패했습니다.") return 1 - - # 8. Worktree 생성 - print_step("🔄", "Worktree 생성 중...") - - result = create_worktree(branch_name, worktree_path) - - if result['success']: - if result['is_existing']: - print_info(result['message']) - else: - print_success(result['message']) - - print() - print_step("📍", f"경로: {result['path']}") - return 0 + + print_success(f"리모트 브랜치(origin/{branch_name})를 기반으로 로컬 브랜치 생성 완료!") else: - print_error(result['message']) + # 리모트에도 없으면 현재 브랜치에서 새로 생성 + current_branch = get_current_branch() + if current_branch: + print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") + else: + print_step("🔄", "새 브랜치 생성 중...") + + if not create_branch(branch_name): + print_error("브랜치 생성에 실패했습니다.") return 1 + print_success("새 브랜치 생성 완료!") + + print() + + # 5. Worktree 경로 계산 + try: + worktree_path = get_worktree_path(branch_name) + except RuntimeError as e: + print_error(str(e)) + return 1 + + print_step("📂", f"Worktree 경로: {worktree_path}") + print() + + # 6. Worktree 존재 확인 + print_step("🔍", "Worktree 확인 중...") + + if is_worktree_exists(worktree_path): + print_info("Worktree가 이미 존재합니다.") + print() + print_step("📍", f"경로: {worktree_path.resolve()}") + return 0 + + # 7. Worktree 루트 디렉토리 생성 + worktree_root = get_worktree_root() + if not ensure_directory(worktree_root): + return 1 + + # 8. Worktree 생성 + print_step("🔄", "Worktree 생성 중...") + + result = create_worktree(branch_name, worktree_path) + + if result['success']: + if result['is_existing']: + print_info(result['message']) + else: + print_success(result['message']) + + print() + print_step("📍", f"경로: {result['path']}") + return 0 + else: + print_error(result['message']) + return 1 + # =================================================================== # 엔트리 포인트 # =================================================================== if __name__ == "__main__": - try: - exit_code = main() - sys.exit(exit_code) - except KeyboardInterrupt: - print() - print_warning("사용자에 의해 중단되었습니다.") - sys.exit(130) - except Exception as e: - print() - print_error(f"예상치 못한 오류가 발생했습니다: {e}") - sys.exit(1) + try: + exit_code = main() + sys.exit(exit_code) + except KeyboardInterrupt: + print() + print_warning("사용자에 의해 중단되었습니다.") + sys.exit(130) + except Exception as e: + print() + print_error(f"예상치 못한 오류가 발생했습니다: {e}") + sys.exit(1) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0bfff89 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# MapSy-BE Project Conventions + +## Project Structure +- Multi-module Gradle project (Java 21, Spring Boot 4.0) +- Modules: MS-Common, MS-Auth, MS-Member, MS-Place, MS-SNS, MS-AI, MS-Web + +## Controller Pattern +- Controller는 `MS-Web` 모듈에 위치 +- `XxxController`는 반드시 `XxxControllerDocs` 인터페이스를 implements +- `XxxControllerDocs`에 Swagger `@Operation`, `@ApiLog` 어노테이션 정의 +- `@AuthenticationPrincipal CustomUserDetails userDetails`로 인증 처리 +- `@RestController`, `@RequiredArgsConstructor`, `@Slf4j`, `@RequestMapping` 사용 + +## DTO Pattern (Request/Response) +- **각 API 메서드마다 별도의 Request/Response DTO를 생성** +- Request 네이밍: `{동작}{도메인}Request` (예: `CreateFolderRequest`, `UpdateFolderRequest`) +- Response 네이밍: `{동작}{도메인}Response` (예: `GetFoldersResponse`, `CreateFolderResponse`) +- Response는 DB Entity 필드를 그대로 DTO로 변환하여 반환 (불필요한 가공 없이) +- DTO는 각 도메인 모듈의 `dto` 패키지에 위치 (예: `MS-Place/...place/dto/`) +- Lombok: `@Getter`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor` +- Swagger: `@Schema` 어노테이션으로 필드 설명 +- 엔티티 → DTO 변환은 `from()` 정적 팩토리 메서드 사용 + +## Service Pattern +- Service는 각 도메인 모듈에 위치 (예: `MS-Place/...place/service/`) +- `@Service`, `@RequiredArgsConstructor`, `@Slf4j` +- 클래스 레벨: `@Transactional(readOnly = true)` 기본 +- 변경 메서드만: `@Transactional` +- Member 파라미터와 UUID 파라미터 오버로드 메서드 패턴 + +## Repository Pattern +- JPA Repository, `extends JpaRepository` +- Soft Delete 고려: `deletedAtIsNull` 조건 항상 포함 +- N+1 방지: `@Query` + `JOIN FETCH` 사용 +- `@Repository` 어노테이션 사용 + +## Entity Pattern +- `SoftDeletableBaseEntity` 상속 (isDeleted, deletedAt, deletedBy) +- UUID PK: `@GeneratedValue(strategy = GenerationType.UUID)` +- Lombok: `@Entity`, `@Builder`, `@Getter`, `@NoArgsConstructor(access = PROTECTED)`, `@AllArgsConstructor(access = PRIVATE)` + +## Error Handling +- `ErrorCode` enum에 에러 코드 정의 (HttpStatus + message) +- `CustomException(ErrorCode)` throw +- `ErrorResponse` 표준 응답 형식 + +## Commit Message Convention +- `{이슈 제목} : {type} : {변경 사항 설명} {이슈 URL}` +- type: feat, fix, refactor, docs, chore, test diff --git a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java index cec49d3..0238c68 100644 --- a/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java +++ b/MS-Auth/src/main/java/kr/suhsaechan/mapsy/auth/service/AuthService.java @@ -20,6 +20,7 @@ import kr.suhsaechan.mapsy.member.repository.MemberRepository; import kr.suhsaechan.mapsy.member.service.MemberService; import kr.suhsaechan.mapsy.member.service.NicknameService; +import kr.suhsaechan.mapsy.place.service.FolderService; import io.jsonwebtoken.ExpiredJwtException; import java.time.LocalDateTime; import java.util.UUID; @@ -46,6 +47,7 @@ public class AuthService { private final FcmTokenRepository fcmTokenRepository; private final FirebaseTokenService firebaseTokenService; private final NicknameService nicknameService; + private final FolderService folderService; /** * Firebase OAuth 로그인 로직 @@ -77,6 +79,8 @@ public SignInResponse signIn(SignInRequest request) { .build(); memberRepository.save(newMember); + // 기본 폴더 자동 생성 + folderService.createDefaultFolder(newMember); log.info("신규 회원 가입 - email={}, nickname={}", email, randomNickname); return newMember; }); diff --git a/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java b/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java index 94fdff9..3a1cced 100644 --- a/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java +++ b/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java @@ -114,7 +114,18 @@ public enum ErrorCode { CANNOT_DELETE_SAVED_PLACE(HttpStatus.BAD_REQUEST, "임시 저장된 장소만 삭제할 수 있습니다."), - INVALID_RATING(HttpStatus.BAD_REQUEST, "별점은 1-5 사이의 값이어야 합니다."); + INVALID_RATING(HttpStatus.BAD_REQUEST, "별점은 1-5 사이의 값이어야 합니다."), + + // Folder + FOLDER_NOT_FOUND(HttpStatus.NOT_FOUND, "폴더를 찾을 수 없습니다."), + + FOLDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 폴더에 대한 접근 권한이 없습니다."), + + CANNOT_DELETE_DEFAULT_FOLDER(HttpStatus.BAD_REQUEST, "기본 폴더는 삭제할 수 없습니다."), + + FOLDER_PLACE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 해당 폴더에 추가된 장소입니다."), + + FOLDER_PLACE_NOT_FOUND(HttpStatus.NOT_FOUND, "폴더에서 해당 장소를 찾을 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/AddFolderPlaceRequest.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/AddFolderPlaceRequest.java new file mode 100644 index 0000000..0d5e5ea --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/AddFolderPlaceRequest.java @@ -0,0 +1,18 @@ +package kr.suhsaechan.mapsy.place.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더에 장소 추가 요청") +public class AddFolderPlaceRequest { + @Schema(description = "추가할 장소 ID", example = "550e8400-e29b-41d4-a716-446655440000") + private UUID placeId; +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/AddFolderPlaceResponse.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/AddFolderPlaceResponse.java new file mode 100644 index 0000000..ddc2ce3 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/AddFolderPlaceResponse.java @@ -0,0 +1,42 @@ +package kr.suhsaechan.mapsy.place.dto; + +import kr.suhsaechan.mapsy.place.entity.FolderPlace; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더에 장소 추가 응답") +public class AddFolderPlaceResponse { + @Schema(description = "폴더-장소 연결 ID") + private UUID id; + + @Schema(description = "폴더 ID") + private UUID folderId; + + @Schema(description = "장소 ID") + private UUID placeId; + + @Schema(description = "정렬 순서") + private int position; + + @Schema(description = "생성일시") + private LocalDateTime createdAt; + + public static AddFolderPlaceResponse from(FolderPlace folderPlace) { + return AddFolderPlaceResponse.builder() + .id(folderPlace.getId()) + .folderId(folderPlace.getFolder().getId()) + .placeId(folderPlace.getPlace().getId()) + .position(folderPlace.getPosition()) + .createdAt(folderPlace.getCreatedAt()) + .build(); + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/CreateFolderRequest.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/CreateFolderRequest.java new file mode 100644 index 0000000..7c87c7f --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/CreateFolderRequest.java @@ -0,0 +1,21 @@ +package kr.suhsaechan.mapsy.place.dto; + +import kr.suhsaechan.mapsy.place.constant.FolderVisibility; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더 생성 요청") +public class CreateFolderRequest { + @Schema(description = "폴더 이름", example = "가고 싶은 곳") + private String name; + + @Schema(description = "공개 설정", example = "PRIVATE") + private FolderVisibility visibility; +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/CreateFolderResponse.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/CreateFolderResponse.java new file mode 100644 index 0000000..2ac65bc --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/CreateFolderResponse.java @@ -0,0 +1,43 @@ +package kr.suhsaechan.mapsy.place.dto; + +import kr.suhsaechan.mapsy.place.constant.FolderVisibility; +import kr.suhsaechan.mapsy.place.entity.Folder; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더 생성 응답") +public class CreateFolderResponse { + @Schema(description = "폴더 ID") + private UUID id; + + @Schema(description = "폴더 이름") + private String name; + + @Schema(description = "공개 설정") + private FolderVisibility visibility; + + @Schema(description = "기본 폴더 여부") + private Boolean isDefault; + + @Schema(description = "생성일시") + private LocalDateTime createdAt; + + public static CreateFolderResponse from(Folder folder) { + return CreateFolderResponse.builder() + .id(folder.getId()) + .name(folder.getName()) + .visibility(folder.getVisibility()) + .isDefault(folder.getIsDefault()) + .createdAt(folder.getCreatedAt()) + .build(); + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/FolderDto.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/FolderDto.java new file mode 100644 index 0000000..12aa2be --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/FolderDto.java @@ -0,0 +1,51 @@ +package kr.suhsaechan.mapsy.place.dto; + +import kr.suhsaechan.mapsy.place.constant.FolderVisibility; +import kr.suhsaechan.mapsy.place.entity.Folder; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더 정보") +public class FolderDto { + @Schema(description = "폴더 ID") + private UUID id; + + @Schema(description = "폴더 이름") + private String name; + + @Schema(description = "공개 설정") + private FolderVisibility visibility; + + @Schema(description = "썸네일 URL") + private String thumbnailUrl; + + @Schema(description = "기본 폴더 여부") + private Boolean isDefault; + + @Schema(description = "폴더 내 장소 수") + private int placeCount; + + @Schema(description = "생성일시") + private LocalDateTime createdAt; + + public static FolderDto from(Folder folder, int placeCount) { + return FolderDto.builder() + .id(folder.getId()) + .name(folder.getName()) + .visibility(folder.getVisibility()) + .thumbnailUrl(folder.getThumbnailUrl()) + .isDefault(folder.getIsDefault()) + .placeCount(placeCount) + .createdAt(folder.getCreatedAt()) + .build(); + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/GetFolderPlacesResponse.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/GetFolderPlacesResponse.java new file mode 100644 index 0000000..8f9a327 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/GetFolderPlacesResponse.java @@ -0,0 +1,25 @@ +package kr.suhsaechan.mapsy.place.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더 내 장소 목록 조회 응답") +public class GetFolderPlacesResponse { + @Schema(description = "폴더 ID") + private UUID folderId; + + @Schema(description = "폴더 이름") + private String folderName; + + @Schema(description = "장소 목록") + private List places; +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/GetFoldersResponse.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/GetFoldersResponse.java new file mode 100644 index 0000000..eef7bb7 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/GetFoldersResponse.java @@ -0,0 +1,18 @@ +package kr.suhsaechan.mapsy.place.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더 목록 조회 응답") +public class GetFoldersResponse { + @Schema(description = "폴더 목록") + private List folders; +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateFolderRequest.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateFolderRequest.java new file mode 100644 index 0000000..484deff --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateFolderRequest.java @@ -0,0 +1,21 @@ +package kr.suhsaechan.mapsy.place.dto; + +import kr.suhsaechan.mapsy.place.constant.FolderVisibility; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더 수정 요청") +public class UpdateFolderRequest { + @Schema(description = "폴더 이름", example = "맛집 모음") + private String name; + + @Schema(description = "공개 설정", example = "SHARED") + private FolderVisibility visibility; +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateFolderResponse.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateFolderResponse.java new file mode 100644 index 0000000..184fc37 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateFolderResponse.java @@ -0,0 +1,43 @@ +package kr.suhsaechan.mapsy.place.dto; + +import kr.suhsaechan.mapsy.place.constant.FolderVisibility; +import kr.suhsaechan.mapsy.place.entity.Folder; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "폴더 수정 응답") +public class UpdateFolderResponse { + @Schema(description = "폴더 ID") + private UUID id; + + @Schema(description = "폴더 이름") + private String name; + + @Schema(description = "공개 설정") + private FolderVisibility visibility; + + @Schema(description = "기본 폴더 여부") + private Boolean isDefault; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; + + public static UpdateFolderResponse from(Folder folder) { + return UpdateFolderResponse.builder() + .id(folder.getId()) + .name(folder.getName()) + .visibility(folder.getVisibility()) + .isDefault(folder.getIsDefault()) + .updatedAt(folder.getUpdatedAt()) + .build(); + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java index 7a72382..3e7f3d7 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java @@ -52,6 +52,10 @@ public class Folder extends SoftDeletableBaseEntity { @Column(length = 500) private String thumbnailUrl; + @Column(nullable = false) + @Builder.Default + private Boolean isDefault = false; + @PrePersist protected void onCreate() { if (name == null) { diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java index ebbb34a..00ba5af 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/MemberPlace.java @@ -75,16 +75,6 @@ public class MemberPlace extends SoftDeletableBaseEntity { */ private LocalDateTime savedAt; - /** - * 북마크 폴더명 - * - 사용자가 장소를 분류하기 위한 폴더 - * - 예: "가고 싶은 곳", "가본 곳", "즐겨찾기", "맛집" 등 - * - 기본값: "default" - */ - @Column(length = 50) - @Builder.Default - private String folder = "default"; - /** * 사용자 메모 * - 장소에 대한 개인적인 메모 (최대 1000자) @@ -134,15 +124,6 @@ public void markAsSaved() { this.savedAt = LocalDateTime.now(); } - /** - * 폴더 변경 - * - * @param newFolder 새 폴더명 - */ - public void updateFolder(String newFolder) { - this.folder = newFolder; - } - /** * 메모 수정 * diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderPlaceRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderPlaceRepository.java new file mode 100644 index 0000000..2c599e4 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderPlaceRepository.java @@ -0,0 +1,35 @@ +package kr.suhsaechan.mapsy.place.repository; + +import kr.suhsaechan.mapsy.place.entity.Folder; +import kr.suhsaechan.mapsy.place.entity.FolderPlace; +import kr.suhsaechan.mapsy.place.entity.Place; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface FolderPlaceRepository extends JpaRepository { + + @Query("SELECT fp FROM FolderPlace fp " + + "JOIN FETCH fp.place " + + "WHERE fp.folder = :folder " + + "AND fp.deletedAt IS NULL " + + "ORDER BY fp.position ASC") + List findByFolderWithPlaceOrderByPosition(@Param("folder") Folder folder); + + Optional findByFolderAndPlaceAndDeletedAtIsNull(Folder folder, Place place); + + boolean existsByFolderAndPlaceAndDeletedAtIsNull(Folder folder, Place place); + + List findByFolderAndDeletedAtIsNull(Folder folder); + + @Query("SELECT COUNT(fp) FROM FolderPlace fp WHERE fp.folder = :folder AND fp.deletedAt IS NULL") + int countByFolderAndDeletedAtIsNull(@Param("folder") Folder folder); + + @Query("SELECT COALESCE(MAX(fp.position), 0) FROM FolderPlace fp WHERE fp.folder = :folder AND fp.deletedAt IS NULL") + int findMaxPositionByFolder(@Param("folder") Folder folder); +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderRepository.java new file mode 100644 index 0000000..c8a76c9 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderRepository.java @@ -0,0 +1,19 @@ +package kr.suhsaechan.mapsy.place.repository; + +import kr.suhsaechan.mapsy.member.entity.Member; +import kr.suhsaechan.mapsy.place.entity.Folder; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface FolderRepository extends JpaRepository { + + List findByOwnerAndDeletedAtIsNullOrderByCreatedAtAsc(Member owner); + + Optional findByOwnerAndIsDefaultTrueAndDeletedAtIsNull(Member owner); + + Optional findByIdAndDeletedAtIsNull(UUID id); +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/FolderService.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/FolderService.java new file mode 100644 index 0000000..df3db58 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/FolderService.java @@ -0,0 +1,290 @@ +package kr.suhsaechan.mapsy.place.service; + +import kr.suhsaechan.mapsy.common.exception.CustomException; +import kr.suhsaechan.mapsy.common.exception.constant.ErrorCode; +import kr.suhsaechan.mapsy.member.entity.Member; +import kr.suhsaechan.mapsy.member.repository.MemberRepository; +import kr.suhsaechan.mapsy.place.constant.FolderVisibility; +import kr.suhsaechan.mapsy.place.dto.AddFolderPlaceRequest; +import kr.suhsaechan.mapsy.place.dto.AddFolderPlaceResponse; +import kr.suhsaechan.mapsy.place.dto.CreateFolderRequest; +import kr.suhsaechan.mapsy.place.dto.CreateFolderResponse; +import kr.suhsaechan.mapsy.place.dto.FolderDto; +import kr.suhsaechan.mapsy.place.dto.GetFolderPlacesResponse; +import kr.suhsaechan.mapsy.place.dto.GetFoldersResponse; +import kr.suhsaechan.mapsy.place.dto.PlaceDto; +import kr.suhsaechan.mapsy.place.dto.UpdateFolderRequest; +import kr.suhsaechan.mapsy.place.dto.UpdateFolderResponse; +import kr.suhsaechan.mapsy.place.entity.Folder; +import kr.suhsaechan.mapsy.place.entity.FolderPlace; +import kr.suhsaechan.mapsy.place.entity.Place; +import kr.suhsaechan.mapsy.place.repository.FolderPlaceRepository; +import kr.suhsaechan.mapsy.place.repository.FolderRepository; +import kr.suhsaechan.mapsy.place.repository.PlaceRepository; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class FolderService { + + private final FolderRepository folderRepository; + private final FolderPlaceRepository folderPlaceRepository; + private final PlaceRepository placeRepository; + private final MemberRepository memberRepository; + + // ========== 폴더 CRUD ========== + + public GetFoldersResponse getFolders(Member member) { + log.info("Getting folders for member: {}", member.getId()); + + List folders = folderRepository.findByOwnerAndDeletedAtIsNullOrderByCreatedAtAsc(member); + + List folderDtos = folders.stream() + .map(folder -> { + int placeCount = folderPlaceRepository.countByFolderAndDeletedAtIsNull(folder); + return FolderDto.from(folder, placeCount); + }) + .collect(Collectors.toList()); + + return GetFoldersResponse.builder() + .folders(folderDtos) + .build(); + } + + @Transactional + public CreateFolderResponse createFolder(Member member, CreateFolderRequest request) { + log.info("Creating folder for member: {}, name: {}", member.getId(), request.getName()); + + Folder folder = Folder.builder() + .owner(member) + .name(request.getName() != null ? request.getName() : "제목 없음") + .visibility(request.getVisibility() != null ? request.getVisibility() : FolderVisibility.PRIVATE) + .isDefault(false) + .build(); + + Folder savedFolder = folderRepository.save(folder); + log.info("Folder created: folderId={}", savedFolder.getId()); + + return CreateFolderResponse.from(savedFolder); + } + + @Transactional + public UpdateFolderResponse updateFolder(Member member, UUID folderId, UpdateFolderRequest request) { + log.info("Updating folder: folderId={}, memberId={}", folderId, member.getId()); + + Folder folder = getFolderWithOwnerValidation(folderId, member); + + if (request.getName() != null) { + folder.setName(request.getName()); + } + if (request.getVisibility() != null) { + folder.setVisibility(request.getVisibility()); + } + + Folder savedFolder = folderRepository.save(folder); + log.info("Folder updated: folderId={}", savedFolder.getId()); + + return UpdateFolderResponse.from(savedFolder); + } + + @Transactional + public void deleteFolder(Member member, UUID folderId) { + log.info("Deleting folder: folderId={}, memberId={}", folderId, member.getId()); + + Folder folder = getFolderWithOwnerValidation(folderId, member); + + // 기본 폴더 삭제 불가 + if (folder.getIsDefault()) { + throw new CustomException(ErrorCode.CANNOT_DELETE_DEFAULT_FOLDER); + } + + // 하위 FolderPlace 모두 Soft Delete + List folderPlaces = folderPlaceRepository.findByFolderAndDeletedAtIsNull(folder); + for (FolderPlace fp : folderPlaces) { + fp.softDelete(member.getId().toString()); + } + folderPlaceRepository.saveAll(folderPlaces); + + // 폴더 Soft Delete + folder.softDelete(member.getId().toString()); + folderRepository.save(folder); + + log.info("Folder deleted: folderId={}, deletedPlaces={}", folderId, folderPlaces.size()); + } + + // ========== 폴더-장소 관리 ========== + + public GetFolderPlacesResponse getFolderPlaces(Member member, UUID folderId) { + log.info("Getting folder places: folderId={}, memberId={}", folderId, member.getId()); + + Folder folder = getFolderWithOwnerValidation(folderId, member); + + List folderPlaces = folderPlaceRepository.findByFolderWithPlaceOrderByPosition(folder); + + List places = folderPlaces.stream() + .map(FolderPlace::getPlace) + .map(PlaceDto::from) + .collect(Collectors.toList()); + + return GetFolderPlacesResponse.builder() + .folderId(folder.getId()) + .folderName(folder.getName()) + .places(places) + .build(); + } + + @Transactional + public AddFolderPlaceResponse addPlaceToFolder(Member member, UUID folderId, AddFolderPlaceRequest request) { + log.info("Adding place to folder: folderId={}, placeId={}, memberId={}", folderId, request.getPlaceId(), member.getId()); + + Folder folder = getFolderWithOwnerValidation(folderId, member); + + Place place = placeRepository.findById(request.getPlaceId()) + .orElseThrow(() -> new CustomException(ErrorCode.PLACE_NOT_FOUND)); + + // 중복 체크 + if (folderPlaceRepository.existsByFolderAndPlaceAndDeletedAtIsNull(folder, place)) { + throw new CustomException(ErrorCode.FOLDER_PLACE_ALREADY_EXISTS); + } + + int maxPosition = folderPlaceRepository.findMaxPositionByFolder(folder); + + FolderPlace folderPlace = FolderPlace.builder() + .folder(folder) + .place(place) + .position(maxPosition + 1) + .build(); + + FolderPlace savedFolderPlace = folderPlaceRepository.save(folderPlace); + log.info("Place added to folder: folderPlaceId={}", savedFolderPlace.getId()); + + return AddFolderPlaceResponse.from(savedFolderPlace); + } + + @Transactional + public void removePlaceFromFolder(Member member, UUID folderId, UUID placeId) { + log.info("Removing place from folder: folderId={}, placeId={}, memberId={}", folderId, placeId, member.getId()); + + Folder folder = getFolderWithOwnerValidation(folderId, member); + + Place place = placeRepository.findById(placeId) + .orElseThrow(() -> new CustomException(ErrorCode.PLACE_NOT_FOUND)); + + FolderPlace folderPlace = folderPlaceRepository.findByFolderAndPlaceAndDeletedAtIsNull(folder, place) + .orElseThrow(() -> new CustomException(ErrorCode.FOLDER_PLACE_NOT_FOUND)); + + folderPlace.softDelete(member.getId().toString()); + folderPlaceRepository.save(folderPlace); + + log.info("Place removed from folder: folderPlaceId={}", folderPlace.getId()); + } + + // ========== 기본 폴더 관련 ========== + + @Transactional + public Folder createDefaultFolder(Member member) { + log.info("Creating default folder for member: {}", member.getId()); + + Folder defaultFolder = Folder.builder() + .owner(member) + .name("기본") + .visibility(FolderVisibility.PRIVATE) + .isDefault(true) + .build(); + + Folder saved = folderRepository.save(defaultFolder); + log.info("Default folder created: folderId={}", saved.getId()); + return saved; + } + + @Transactional + public void addPlaceToDefaultFolder(Member member, Place place) { + Folder defaultFolder = folderRepository.findByOwnerAndIsDefaultTrueAndDeletedAtIsNull(member) + .orElseGet(() -> createDefaultFolder(member)); + + // 이미 기본 폴더에 있으면 스킵 + if (folderPlaceRepository.existsByFolderAndPlaceAndDeletedAtIsNull(defaultFolder, place)) { + log.debug("Place already in default folder: placeId={}", place.getId()); + return; + } + + int maxPosition = folderPlaceRepository.findMaxPositionByFolder(defaultFolder); + + FolderPlace folderPlace = FolderPlace.builder() + .folder(defaultFolder) + .place(place) + .position(maxPosition + 1) + .build(); + + folderPlaceRepository.save(folderPlace); + log.info("Place added to default folder: placeId={}", place.getId()); + } + + // ========== UUID 오버로드 메서드 ========== + + public GetFoldersResponse getFolders(UUID memberId) { + return getFolders(getMemberById(memberId)); + } + + @Transactional + public CreateFolderResponse createFolder(UUID memberId, CreateFolderRequest request) { + return createFolder(getMemberById(memberId), request); + } + + @Transactional + public UpdateFolderResponse updateFolder(UUID memberId, UUID folderId, UpdateFolderRequest request) { + return updateFolder(getMemberById(memberId), folderId, request); + } + + @Transactional + public void deleteFolder(UUID memberId, UUID folderId) { + deleteFolder(getMemberById(memberId), folderId); + } + + public GetFolderPlacesResponse getFolderPlaces(UUID memberId, UUID folderId) { + return getFolderPlaces(getMemberById(memberId), folderId); + } + + @Transactional + public AddFolderPlaceResponse addPlaceToFolder(UUID memberId, UUID folderId, AddFolderPlaceRequest request) { + return addPlaceToFolder(getMemberById(memberId), folderId, request); + } + + @Transactional + public void removePlaceFromFolder(UUID memberId, UUID folderId, UUID placeId) { + removePlaceFromFolder(getMemberById(memberId), folderId, placeId); + } + + // ========== Private Helper ========== + + private Member getMemberById(UUID memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> { + log.error("Member not found: memberId={}", memberId); + return new CustomException(ErrorCode.MEMBER_NOT_FOUND); + }); + } + + private Folder getFolderWithOwnerValidation(UUID folderId, Member member) { + Folder folder = folderRepository.findByIdAndDeletedAtIsNull(folderId) + .orElseThrow(() -> { + log.error("Folder not found: folderId={}", folderId); + return new CustomException(ErrorCode.FOLDER_NOT_FOUND); + }); + + if (!folder.getOwner().getId().equals(member.getId())) { + log.error("Folder access denied: folderId={}, memberId={}", folderId, member.getId()); + throw new CustomException(ErrorCode.FOLDER_ACCESS_DENIED); + } + + return folder; + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java index b14c818..f3d55a4 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java @@ -30,6 +30,7 @@ public class MemberPlaceService { private final MemberPlaceRepository memberPlaceRepository; private final PlaceRepository placeRepository; private final MemberRepository memberRepository; + private final FolderService folderService; /** * 회원의 임시 저장 장소 목록 조회 @@ -112,6 +113,10 @@ public SavePlaceResponse savePlace(Member member, UUID placeId) { // 3. 상태 변경 (TEMPORARY → SAVED) memberPlace.markAsSaved(); + + // 4. 기본 폴더에 자동 배치 + folderService.addPlaceToDefaultFolder(member, place); + MemberPlace savedMemberPlace = memberPlaceRepository.save(memberPlace); log.info("Place saved successfully: memberPlaceId={}", savedMemberPlace.getId()); diff --git a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderController.java b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderController.java new file mode 100644 index 0000000..1781be5 --- /dev/null +++ b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderController.java @@ -0,0 +1,116 @@ +package kr.suhsaechan.mapsy.web.controller; + +import kr.suhsaechan.mapsy.auth.dto.CustomUserDetails; +import kr.suhsaechan.mapsy.place.dto.AddFolderPlaceRequest; +import kr.suhsaechan.mapsy.place.dto.AddFolderPlaceResponse; +import kr.suhsaechan.mapsy.place.dto.CreateFolderRequest; +import kr.suhsaechan.mapsy.place.dto.CreateFolderResponse; +import kr.suhsaechan.mapsy.place.dto.GetFolderPlacesResponse; +import kr.suhsaechan.mapsy.place.dto.GetFoldersResponse; +import kr.suhsaechan.mapsy.place.dto.UpdateFolderRequest; +import kr.suhsaechan.mapsy.place.dto.UpdateFolderResponse; +import kr.suhsaechan.mapsy.place.service.FolderService; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/folders") +public class FolderController implements FolderControllerDocs { + + private final FolderService folderService; + + @GetMapping + @Override + public ResponseEntity getFolders( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Get folders request from member: {}", userDetails.getMemberId()); + GetFoldersResponse response = folderService.getFolders(userDetails.getMemberId()); + return ResponseEntity.ok(response); + } + + @PostMapping + @Override + public ResponseEntity createFolder( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody CreateFolderRequest request + ) { + log.info("Create folder request from member: {}", userDetails.getMemberId()); + CreateFolderResponse response = folderService.createFolder(userDetails.getMemberId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PutMapping("/{folderId}") + @Override + public ResponseEntity updateFolder( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable UUID folderId, + @RequestBody UpdateFolderRequest request + ) { + log.info("Update folder request from member: {}, folderId: {}", userDetails.getMemberId(), folderId); + UpdateFolderResponse response = folderService.updateFolder(userDetails.getMemberId(), folderId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{folderId}") + @Override + public ResponseEntity deleteFolder( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable UUID folderId + ) { + log.info("Delete folder request from member: {}, folderId: {}", userDetails.getMemberId(), folderId); + folderService.deleteFolder(userDetails.getMemberId(), folderId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{folderId}/places") + @Override + public ResponseEntity getFolderPlaces( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable UUID folderId + ) { + log.info("Get folder places request from member: {}, folderId: {}", userDetails.getMemberId(), folderId); + GetFolderPlacesResponse response = folderService.getFolderPlaces(userDetails.getMemberId(), folderId); + return ResponseEntity.ok(response); + } + + @PostMapping("/{folderId}/places") + @Override + public ResponseEntity addPlaceToFolder( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable UUID folderId, + @RequestBody AddFolderPlaceRequest request + ) { + log.info("Add place to folder request from member: {}, folderId: {}, placeId: {}", + userDetails.getMemberId(), folderId, request.getPlaceId()); + AddFolderPlaceResponse response = folderService.addPlaceToFolder(userDetails.getMemberId(), folderId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @DeleteMapping("/{folderId}/places/{placeId}") + @Override + public ResponseEntity removePlaceFromFolder( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable UUID folderId, + @PathVariable UUID placeId + ) { + log.info("Remove place from folder request from member: {}, folderId: {}, placeId: {}", + userDetails.getMemberId(), folderId, placeId); + folderService.removePlaceFromFolder(userDetails.getMemberId(), folderId, placeId); + return ResponseEntity.noContent().build(); + } +} diff --git a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderControllerDocs.java b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderControllerDocs.java new file mode 100644 index 0000000..5533072 --- /dev/null +++ b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderControllerDocs.java @@ -0,0 +1,209 @@ +package kr.suhsaechan.mapsy.web.controller; + +import kr.suhsaechan.mapsy.auth.dto.CustomUserDetails; +import kr.suhsaechan.mapsy.place.dto.AddFolderPlaceRequest; +import kr.suhsaechan.mapsy.place.dto.AddFolderPlaceResponse; +import kr.suhsaechan.mapsy.place.dto.CreateFolderRequest; +import kr.suhsaechan.mapsy.place.dto.CreateFolderResponse; +import kr.suhsaechan.mapsy.place.dto.GetFolderPlacesResponse; +import kr.suhsaechan.mapsy.place.dto.GetFoldersResponse; +import kr.suhsaechan.mapsy.place.dto.UpdateFolderRequest; +import kr.suhsaechan.mapsy.place.dto.UpdateFolderResponse; +import io.swagger.v3.oas.annotations.Operation; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +public interface FolderControllerDocs { + + @Operation(summary = "내 폴더 목록 조회", description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 + - 없음 + + ## 반환값 (GetFoldersResponse) + - **`folders`**: 폴더 목록 (List) + - **`id`**: 폴더 ID + - **`name`**: 폴더 이름 + - **`visibility`**: 공개 설정 (PRIVATE, SHARED) + - **`thumbnailUrl`**: 썸네일 URL + - **`isDefault`**: 기본 폴더 여부 + - **`placeCount`**: 폴더 내 장소 수 + - **`createdAt`**: 생성일시 + + ## 특이사항 + - 로그인한 사용자의 모든 폴더를 조회합니다. + - 기본 폴더를 포함하여 반환됩니다. + + ## 에러코드 + - **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다. + """) + ResponseEntity getFolders( + CustomUserDetails userDetails + ); + + @Operation(summary = "폴더 생성", description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 + - **`name`**: 폴더 이름 (필수, RequestBody) + - **`visibility`**: 공개 설정 (필수, RequestBody) - PRIVATE, SHARED + + ## 반환값 (CreateFolderResponse) + - **`id`**: 폴더 ID + - **`name`**: 폴더 이름 + - **`visibility`**: 공개 설정 + - **`isDefault`**: 기본 폴더 여부 + - **`createdAt`**: 생성일시 + + ## 특이사항 + - 새로 생성되는 폴더의 isDefault는 항상 false입니다. + + ## 에러코드 + - **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다. + """) + ResponseEntity createFolder( + CustomUserDetails userDetails, + CreateFolderRequest request + ); + + @Operation(summary = "폴더 수정", description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 + - **`folderId`**: 수정할 폴더 ID (필수, Path Variable) + - **`name`**: 폴더 이름 (선택, RequestBody) + - **`visibility`**: 공개 설정 (선택, RequestBody) - PRIVATE, SHARED + + ## 반환값 (UpdateFolderResponse) + - **`id`**: 폴더 ID + - **`name`**: 폴더 이름 + - **`visibility`**: 공개 설정 + - **`isDefault`**: 기본 폴더 여부 + - **`updatedAt`**: 수정일시 + + ## 특이사항 + - 본인의 폴더만 수정할 수 있습니다. + + ## 에러코드 + - **`FOLDER_NOT_FOUND`**: 폴더를 찾을 수 없습니다. + - **`FOLDER_ACCESS_DENIED`**: 폴더에 대한 접근 권한이 없습니다. + """) + ResponseEntity updateFolder( + CustomUserDetails userDetails, + UUID folderId, + UpdateFolderRequest request + ); + + @Operation(summary = "폴더 삭제", description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 + - **`folderId`**: 삭제할 폴더 ID (필수, Path Variable) + + ## 반환값 + - **204 No Content**: 삭제 성공 (반환값 없음) + + ## 특이사항 + - 본인의 폴더만 삭제할 수 있습니다. + - 기본 폴더는 삭제할 수 없습니다. + - 폴더 삭제 시 폴더 내 장소 연결도 함께 삭제됩니다. + + ## 에러코드 + - **`FOLDER_NOT_FOUND`**: 폴더를 찾을 수 없습니다. + - **`FOLDER_ACCESS_DENIED`**: 폴더에 대한 접근 권한이 없습니다. + - **`CANNOT_DELETE_DEFAULT_FOLDER`**: 기본 폴더는 삭제할 수 없습니다. + """) + ResponseEntity deleteFolder( + CustomUserDetails userDetails, + UUID folderId + ); + + @Operation(summary = "폴더 내 장소 목록 조회", description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 + - **`folderId`**: 조회할 폴더 ID (필수, Path Variable) + + ## 반환값 (GetFolderPlacesResponse) + - **`folderId`**: 폴더 ID + - **`folderName`**: 폴더 이름 + - **`places`**: 장소 목록 (List) + - **`placeId`**: 장소 ID + - **`name`**: 장소명 + - **`address`**: 주소 + - **`rating`**: 별점 (0.0 ~ 5.0) + - **`userRatingsTotal`**: 리뷰 수 + - **`photoUrls`**: 사진 URL 배열 + - **`description`**: 장소 요약 설명 + + ## 특이사항 + - 본인의 폴더에 포함된 장소 목록을 조회합니다. + - 장소는 position 순서대로 정렬되어 반환됩니다. + + ## 에러코드 + - **`FOLDER_NOT_FOUND`**: 폴더를 찾을 수 없습니다. + - **`FOLDER_ACCESS_DENIED`**: 폴더에 대한 접근 권한이 없습니다. + """) + ResponseEntity getFolderPlaces( + CustomUserDetails userDetails, + UUID folderId + ); + + @Operation(summary = "폴더에 장소 추가", description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 + - **`folderId`**: 폴더 ID (필수, Path Variable) + - **`placeId`**: 추가할 장소 ID (필수, RequestBody) + + ## 반환값 (AddFolderPlaceResponse) + - **`id`**: 폴더-장소 연결 ID + - **`folderId`**: 폴더 ID + - **`placeId`**: 장소 ID + - **`position`**: 정렬 순서 + - **`createdAt`**: 생성일시 + + ## 특이사항 + - 본인의 폴더에만 장소를 추가할 수 있습니다. + - 동일한 폴더에 같은 장소를 중복 추가할 수 없습니다. + - position은 폴더 내 마지막 순서로 자동 배정됩니다. + + ## 에러코드 + - **`FOLDER_NOT_FOUND`**: 폴더를 찾을 수 없습니다. + - **`FOLDER_ACCESS_DENIED`**: 폴더에 대한 접근 권한이 없습니다. + - **`PLACE_NOT_FOUND`**: 장소를 찾을 수 없습니다. + - **`FOLDER_PLACE_ALREADY_EXISTS`**: 이미 해당 폴더에 추가된 장소입니다. + """) + ResponseEntity addPlaceToFolder( + CustomUserDetails userDetails, + UUID folderId, + AddFolderPlaceRequest request + ); + + @Operation(summary = "폴더에서 장소 제거", description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 + - **`folderId`**: 폴더 ID (필수, Path Variable) + - **`placeId`**: 제거할 장소 ID (필수, Path Variable) + + ## 반환값 + - **204 No Content**: 제거 성공 (반환값 없음) + + ## 특이사항 + - 본인의 폴더에서만 장소를 제거할 수 있습니다. + - 장소 제거 시 폴더-장소 연결만 삭제되며, 장소 자체는 삭제되지 않습니다. + + ## 에러코드 + - **`FOLDER_NOT_FOUND`**: 폴더를 찾을 수 없습니다. + - **`FOLDER_ACCESS_DENIED`**: 폴더에 대한 접근 권한이 없습니다. + - **`PLACE_NOT_FOUND`**: 장소를 찾을 수 없습니다. + - **`FOLDER_PLACE_NOT_FOUND`**: 폴더에 해당 장소가 존재하지 않습니다. + """) + ResponseEntity removePlaceFromFolder( + CustomUserDetails userDetails, + UUID folderId, + UUID placeId + ); +}