11차 F-11: Git 원격 URL · 계정 · 토큰 저장

- URL · 계정은 Git 탭에 적으면 설정에 남긴다(0.8초 모아 한 번)
- 토큰은 fetch/pull/push/클론이 성공했을 때만 윈도 자격 증명 관리자에(GitCredentialStore, 서버마다 한 이름)
- 켤 때 · 주소를 바꿀 때 그 서버 토큰만 가져오고, 다른 서버로 바꾸면 앞 서버 토큰은 치운다
- [토큰 지우기] 단추 · "저장됨" 표시, 인증 실패는 한글 안내(GitErrors.Friendly)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-23 02:42:25 +09:00
parent c317a7f70b
commit c99723181e
8 changed files with 408 additions and 18 deletions

View File

@ -168,6 +168,7 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀
- F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건. - F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건.
- F-9: 매뉴얼 설정 표 "저장 > 저장할 때 백업"(기본 끔) — 백업 자리 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` · 목록.txt, 모든 저장(병합 중 Save · 파일 단위 Accept · 폴더 복사 포함), 못 만들면 저장 안 함, 저절로 지우지 않음, ACTIONS [백업 폴더 열기]. 숫자 24벌 1,618건. - F-9: 매뉴얼 설정 표 "저장 > 저장할 때 백업"(기본 끔) — 백업 자리 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` · 목록.txt, 모든 저장(병합 중 Save · 파일 단위 Accept · 폴더 복사 포함), 못 만들면 저장 안 함, 저절로 지우지 않음, ACTIONS [백업 폴더 열기]. 숫자 24벌 1,618건.
- F-10: 매뉴얼 파일 비교 화면 설명과 그림을 새로 — 칸마다 자기 줄(빈 줄로 맞추지 않음) · 두 칸(3분할은 위 세 칸) 사이 잇는 선과 색 · 옮김 점선 · 가운데 기준 스크롤 · 차이 이동이 가운데로 · 지운 줄 되살리기 [>] 가 BASE 칸 끝. 상태 줄 `N행` 은 파일 줄 번호. 숫자 25벌 1,639건. - F-10: 매뉴얼 파일 비교 화면 설명과 그림을 새로 — 칸마다 자기 줄(빈 줄로 맞추지 않음) · 두 칸(3분할은 위 세 칸) 사이 잇는 선과 색 · 옮김 점선 · 가운데 기준 스크롤 · 차이 이동이 가운데로 · 지운 줄 되살리기 [>] 가 BASE 칸 끝. 상태 줄 `N행` 은 파일 줄 번호. 숫자 25벌 1,639건.
- F-11: 매뉴얼 Git 탭 원격 칸 — 원격 URL · 계정은 적으면 설정에 남고, 토큰은 fetch/pull/push 가 되면 윈도 자격 증명 관리자에 저장(설정 파일에는 토큰이 없음), 껐다 켜면 다시 묻지 않음, [토큰 지우기] · "저장됨" 표시, 인증 실패 안내 문장. 이 PC · 이 윈도 계정에만 저장된다는 것도. 숫자 25벌 1,654건.
## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료) ## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료)
@ -925,6 +926,49 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커
- 워드랩을 켜면 화면 밖 줄의 높이는 어림이라, 긴 파일을 멀리 건너뛴 직후에는 가운데 맞춤이 몇 줄 어긋날 수 있다(그려지면 바로잡힘 — 예전 방식에도 있던 한계). - 워드랩을 켜면 화면 밖 줄의 높이는 어림이라, 긴 파일을 멀리 건너뛴 직후에는 가운데 맞춤이 몇 줄 어긋날 수 있다(그려지면 바로잡힘 — 예전 방식에도 있던 한계).
- 3분할 MERGED 와 위 칸 사이 선은 두지 않았다(사용자 결정). - 3분할 MERGED 와 위 칸 사이 선은 두지 않았다(사용자 결정).
## F-11. Git 계정 · URL · 토큰이 한 번 쓰면 저장되게 (2026-09-23, 완료)
사용자 결정: 토큰은 **윈도 자격 증명 관리자에 저장**(평문 금지 — 지시서 조건), Git 탭에 적은 **원격 URL · 계정은 적으면 설정에 바로 남김**.
### 고치기 전 (화면으로 확인, `f11-check.ps1` · 가짜 서버 `f11-server.ps1`)
| 확인 | 결과 |
|---|---|
| 계정 · 토큰을 넣고 Fetch | 서버가 `tester:토큰` 을 받았다 — 보내는 것은 정상 |
| 설정 파일 | Git 탭에 적은 URL · 계정 · 토큰이 **아무것도 저장되지 않음**(설정 탭 "사내 Gitea" 값만 켤 때 읽음) |
| 껐다 켬 | 원격 URL · 계정 · 토큰 칸이 **모두 빔** |
| 그 상태로 Fetch | 인증 없이 보내고 실패. 로그는 영어로 `remote authentication required but no callback set` 뿐 |
| 설정 폴더 전체에서 토큰 글자 | 0곳(저장하지 않으니 당연) |
### 고친 것
- `GitCredentialStore`(새) — 윈도 자격 증명 관리자(advapi32 `CredWrite`/`CredRead`/`CredDelete`, 사용자 계정 키로 암호화). 이름은 **서버마다 하나** `MergeHub:git:스킴://호스트[:포트]`(`GitCredentialKey.For`) — 같은 서버의 다른 저장소는 한 번만 넣으면 된다. SSH(`git@…`) · 로컬 경로 원격은 토큰을 쓰지 않아 저장하지 않는다. 못 읽거나 못 쓰면 조용히 넘어간다(토큰은 덤이다).
- **저장은 된 것만** — fetch · pull · push · 클론이 **성공했을 때** 그때의 계정 · 토큰을 맡긴다. 켤 때와 원격 주소를 바꿀 때 그 서버의 토큰을 가져와 토큰 칸에 채운다(PasswordBox 는 바인딩이 안 돼 화면 코드가 넣는다).
- 다른 서버로 주소를 바꾸면 **앞 서버에서 가져온 토큰은 치운다** — 남의 서버로 보내지 않는다. 사람이 직접 적은 토큰은 건드리지 않는다.
- **[토큰 지우기]** 단추와 "저장됨" 표시(맡겨 둔 것이 있을 때만). PC 를 넘기거나 토큰을 새로 받았을 때 쓴다.
- Git 탭 원격 칸의 **URL · 계정을 설정에 남긴다**(글자마다 쓰지 않게 0.8초 모았다가 한 번).
- 실패한 영어 말을 사람 말로(`GitErrors.Friendly`) — "원격이 계정 · 토큰을 요구합니다. Git 탭 원격 칸에 계정과 토큰을 넣고 다시 해 보세요." · "계정 또는 토큰이 맞지 않습니다…". 원래 영어 말은 괄호로 함께 남긴다.
### 확인 (`f11-verify.ps1`, 127.0.0.1 가짜 사내 서버 — 인증이 없거나 토큰이 틀리면 401, 맞으면 `git http-backend` 로 진짜 fetch)
| 경우 | 결과 |
|---|---|
| 계정 · 토큰 넣고 Fetch | `fetch 완료` · 서버가 받은 인증 `tester:f11-secret-…` · 자격 증명 관리자에 저장됨 · 화면에 "저장됨" |
| 설정 파일 | URL 있음 · 계정 있음 · **토큰 글자 없음** |
| 껐다 켬 | URL · 계정 칸이 채워져 있고 "저장됨" 표시 |
| 아무것도 안 넣고 Fetch | `fetch 완료` · 서버가 같은 토큰을 받음 — **다시 묻지 않는다** |
| [토큰 지우기] | 자격 증명 관리자에서 사라짐 · "저장됨" 꺼짐 |
| 그 뒤 Fetch | `[fetch] 실패: 계정 또는 토큰이 맞지 않습니다. Git 탭 원격 칸에서 다시 넣어 주세요. (too many redirects or authentication replays)` |
| 시험 뒷정리 | 시험이 만든 자격 증명 삭제 확인 |
- 하네스 MergeSaveTest **+15건(335 → 350)** — 이름 규칙(서버마다 하나 · 저장소가 달라도 같음 · 대소문자 · 포트 · SSH · 로컬 경로 · 빈 주소 · 말이 안 되는 주소)과 실패한 말 옮기기(토큰 없음 · 틀림 · 401 · 인증과 무관 · 빈 말). Debug 빌드 경고 0 · 오류 0.
- 회귀: 저장 후 커밋 되돌리기(f8-git) · 병합 [다시 충돌로](f8-reopen) 그대로.
### 남은 것
- 사내 Gitea 가 실제로 서면 그 서버로 한 번 확인한다(지금은 127.0.0.1 가짜 서버).
- 자격 증명은 **이 PC · 이 윈도 계정**에만 있다(DPAPI). 다른 PC 에서는 한 번 더 넣어야 한다.
## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료) ## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료)
사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다). 사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다).

View File

@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 | | 날짜 | 항목 | 커밋 |
|---|---|---| |---|---|---|
| 2026-09-23 | **11차 F-11** — Git 원격 인증: 껐다 켜면 URL · 계정 · 토큰이 모두 비어 매번 다시 넣어야 했다. URL · 계정은 Git 탭에 적으면 설정에 남기고(0.8초 모아 한 번), 토큰은 fetch · pull · push · 클론이 **성공했을 때만** 윈도 자격 증명 관리자에 맡긴다(`GitCredentialStore` — 서버마다 한 이름, 설정 파일에는 토큰 글자 없음, SSH · 로컬 경로는 저장 안 함). 켤 때 · 주소를 바꿀 때 그 서버 토큰만 가져오고, 다른 서버로 바꾸면 앞 서버 토큰은 치운다. [토큰 지우기] 단추 · "저장됨" 표시. 인증 실패는 영어 대신 무엇을 하라는 한글 문장으로 | 아래 |
| 2026-09-22 | **11차 F-10** — Araxis 식 화면: 칸마다 자기 줄만(빈 줄로 높이를 맞추지 않음), 두 칸(3분할은 위 세 칸) 사이 틈에 바뀐 덩어리마다 사다리꼴 잇는 선 · 칸 안 경계선(한쪽에 없으면 굵은 선 하나) · 옮긴 덩어리는 청록 점선. 스크롤은 가운데 기준으로 따라옴(`PaneMapping` — 함께 있는 줄 가운데를 짝점, 사이는 비율). 속의 정렬 행은 그대로라 반영 · 되돌리기 · 차이 이동이 그대로 동작. 편집기에서 빈 줄을 없애 `StripFillers` 를 지움(편집기 줄 ↔ 행 표). 지운 줄 [>] 는 BASE 칸 끝, 차이 이동은 가운데로 | 아래 | | 2026-09-22 | **11차 F-10** — Araxis 식 화면: 칸마다 자기 줄만(빈 줄로 높이를 맞추지 않음), 두 칸(3분할은 위 세 칸) 사이 틈에 바뀐 덩어리마다 사다리꼴 잇는 선 · 칸 안 경계선(한쪽에 없으면 굵은 선 하나) · 옮긴 덩어리는 청록 점선. 스크롤은 가운데 기준으로 따라옴(`PaneMapping` — 함께 있는 줄 가운데를 짝점, 사이는 비율). 속의 정렬 행은 그대로라 반영 · 되돌리기 · 차이 이동이 그대로 동작. 편집기에서 빈 줄을 없애 `StripFillers` 를 지움(편집기 줄 ↔ 행 표). 지운 줄 [>] 는 BASE 칸 끝, 차이 이동은 가운데로 | 아래 |
| 2026-09-22 | **11차 F-9** — 저장할 때 백업: 설정 "저장할 때 백업"(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 비교 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` 로 복사(`FileBackup`, 날짜 폴더마다 목록.txt, 저절로 안 지움 — 사용자 결정). 원본 폴더 · 저장소 밖이라 폴더 비교 · 변경분 · git status 에 안 섞임. 못 만들면 저장하지 않음 | 아래 | | 2026-09-22 | **11차 F-9** — 저장할 때 백업: 설정 "저장할 때 백업"(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 비교 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` 로 복사(`FileBackup`, 날짜 폴더마다 목록.txt, 저절로 안 지움 — 사용자 결정). 원본 폴더 · 저장소 밖이라 폴더 비교 · 변경분 · git status 에 안 섞임. 못 만들면 저장하지 않음 | 아래 |
| 2026-09-22 | **11차 F-8** — 저장한 뒤에도 Undo/Redo: 저장하는 길마다 되돌리기 목록을 비우던 것을 두고, "저장할 것 있음" 을 목록이 아니라 "지금 글이 마지막 저장과 다른가" 로(`SaveBaseline`). 커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기 · 편집기에 친 뒤 재정렬처럼 줄을 다시 세울 때 이력을 글 단위로 옮겨 지킴(친 것도 한 걸음, 300걸음까지). 전체 되돌리기는 파일을 연 시점. 병합 중 푼 파일은 Git 탭 [다시 충돌로] 로 되돌림 — LibGit2Sharp 에 길이 없어 이 단추만 `git checkout -m`(사용자 결정), 해결 기록에서도 뺌 | 아래 | | 2026-09-22 | **11차 F-8** — 저장한 뒤에도 Undo/Redo: 저장하는 길마다 되돌리기 목록을 비우던 것을 두고, "저장할 것 있음" 을 목록이 아니라 "지금 글이 마지막 저장과 다른가" 로(`SaveBaseline`). 커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기 · 편집기에 친 뒤 재정렬처럼 줄을 다시 세울 때 이력을 글 단위로 옮겨 지킴(친 것도 한 걸음, 300걸음까지). 전체 되돌리기는 파일을 연 시점. 병합 중 푼 파일은 Git 탭 [다시 충돌로] 로 되돌림 — LibGit2Sharp 에 길이 없어 이 단추만 `git checkout -m`(사용자 결정), 해결 기록에서도 뺌 | 아래 |

View File

@ -380,7 +380,7 @@
| **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 | | **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 |
| **완료** | F-9 저장할 때 백업 — 설정(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\…` 에 모음(목록.txt, 저절로 안 지움). 못 만들면 저장 안 함. ACTIONS [백업 폴더 열기]. 하네스 +25 | | **완료** | F-9 저장할 때 백업 — 설정(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\…` 에 모음(목록.txt, 저절로 안 지움). 못 만들면 저장 안 함. ACTIONS [백업 폴더 열기]. 하네스 +25 |
| **완료** | F-10 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선(Araxis 식, 사용자 요청으로 넓힘) — 빈 줄로 맞추지 않고 칸마다 자기 줄만, 틈에 사다리꼴 선 · 옮김 점선, 가운데 기준 스크롤, 차이 이동은 가운데로, 지운 줄 [>] 는 BASE 칸 끝. 3분할은 위 세 칸 사이. 편집기 빈 줄(StripFillers) 없앰. PaneLinkTest 17 새로 · EditorTest 14 → 18 | | **완료** | F-10 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선(Araxis 식, 사용자 요청으로 넓힘) — 빈 줄로 맞추지 않고 칸마다 자기 줄만, 틈에 사다리꼴 선 · 옮김 점선, 가운데 기준 스크롤, 차이 이동은 가운데로, 지운 줄 [>] 는 BASE 칸 끝. 3분할은 위 세 칸 사이. 편집기 빈 줄(StripFillers) 없앰. PaneLinkTest 17 새로 · EditorTest 14 → 18 |
| 대기 | F-11 Git 계정 · URL · 암호 저장 확인 (지금은 주소 · 계정만 저장, 토큰은 메모리) | | **완료** | F-11 Git 계정 · URL · 토큰 저장 — 확인해 보니 껐다 켜면 셋 다 비었다. URL · 계정은 적으면 설정에 남기고, 토큰은 fetch/pull/push/클론이 성공하면 윈도 자격 증명 관리자에 저장(평문 아님 · 설정 파일에 글자 없음), 켤 때 그 서버 것만 가져옴. [토큰 지우기] · "저장됨" 표시 · 인증 실패 한글 안내. MergeSaveTest +15 |
| **완료** | F-12 C# 코드 스타일 맞춤 기본값을 AURORA 로 (2026-09-18 사용자 결정) — 설정 없는 첫 실행에서 AURORA, 중괄호만 다른 C# 짝 "차이 없음" · 같은 모양의 C++ 짝은 "남은 차이 4줄"(원본 그대로). 저장 동작은 그대로. 하네스 StyleTest +12(90건) | | **완료** | F-12 C# 코드 스타일 맞춤 기본값을 AURORA 로 (2026-09-18 사용자 결정) — 설정 없는 첫 실행에서 AURORA, 중괄호만 다른 C# 짝 "차이 없음" · 같은 모양의 C++ 짝은 "남은 차이 4줄"(원본 그대로). 저장 동작은 그대로. 하네스 StyleTest +12(90건) |
--- ---

View File

@ -0,0 +1,188 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace MergeHub.Services
{
/// <summary>
/// 원격 토큰을 어디에 어떤 이름으로 둘지, 실패한 말을 사람 말로 어떻게 옮길지(11차 F-11). 윈도 없이 검사한다.
/// </summary>
public static class GitCredentialKey
{
/// <summary>자격 증명 이름 앞머리. 다른 프로그램 것과 섞이지 않게 둔다.</summary>
public const string Prefix = "MergeHub:git:";
/// <summary>
/// 이 원격 주소의 토큰을 둘 이름. 서버(호스트 · 포트)마다 따로 둔다 - 저장소가 달라도 같은 서버면 한 번만 넣으면 된다.
/// 주소를 읽을 수 없으면 null(저장하지 않는다 - 로컬 경로 원격에는 토큰이 필요 없다).
/// </summary>
public static string? For(string? remoteUrl)
{
if (string.IsNullOrWhiteSpace(remoteUrl)) return null;
var text = remoteUrl!.Trim();
// git@서버:팀/저장소.git (SSH) 는 토큰을 쓰지 않는다
if (text.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) return null;
if (!Uri.TryCreate(text, UriKind.Absolute, out var uri)) return null;
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return null;
if (string.IsNullOrEmpty(uri.Host)) return null;
var port = uri.IsDefaultPort ? string.Empty : ":" + uri.Port;
return Prefix + uri.Scheme.ToLowerInvariant() + "://" + uri.Host.ToLowerInvariant() + port;
}
}
/// <summary>Git 이 영어로 돌려주는 실패를 사람 말로(11차 F-11).</summary>
public static class GitErrors
{
/// <summary>
/// 인증이 필요하다는 말이면 무엇을 하라는 말로 바꾼다. 아니면 null - 부르는 쪽이 원래 말을 쓴다.
/// </summary>
public static string? Friendly(string? message)
{
if (string.IsNullOrWhiteSpace(message)) return null;
var text = message!.ToLowerInvariant();
var needsLogin = text.Contains("authentication required") ||
text.Contains("no callback set") ||
text.Contains("401") ||
text.Contains("too many redirects or authentication replays") ||
text.Contains("authentication failed") ||
text.Contains("unauthorized");
if (!needsLogin) return null;
var wrong = text.Contains("failed") || text.Contains("401") || text.Contains("unauthorized") ||
text.Contains("replays");
return wrong
? "계정 또는 토큰이 맞지 않습니다. Git 탭 원격 칸에서 다시 넣어 주세요."
: "원격이 계정 · 토큰을 요구합니다. Git 탭 원격 칸에 계정과 토큰을 넣고 다시 해 보세요.";
}
}
/// <summary>
/// 원격 토큰을 <b>윈도 자격 증명 관리자</b>에 맡긴다(11차 F-11, 사용자 결정 - 평문으로 두지 않는다).
///
/// 윈도가 사용자 계정 키로 암호화해 두므로(DPAPI) 다른 계정 · 다른 PC 에서는 읽히지 않고, 설정 파일에는 토큰 글자가 남지 않는다.
/// 이름은 서버마다 하나(<see cref="GitCredentialKey.For"/>). 못 읽거나 못 쓰면 <b>조용히 넘어간다</b> - 토큰은 덤이고,
/// 없으면 사용자가 그때 넣으면 된다.
/// </summary>
public sealed class GitCredentialStore
{
private const int CRED_TYPE_GENERIC = 1;
private const int CRED_PERSIST_LOCAL_MACHINE = 2;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL
{
public int Flags;
public int Type;
public IntPtr TargetName;
public IntPtr Comment;
public long LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public IntPtr TargetAlias;
public IntPtr UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredWriteW")]
private static extern bool CredWrite(ref CREDENTIAL credential, int flags);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredReadW")]
private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredDeleteW")]
private static extern bool CredDelete(string target, int type, int flags);
[DllImport("advapi32.dll")]
private static extern void CredFree(IntPtr buffer);
/// <summary>이 서버의 계정 · 토큰을 맡긴다. 맡겼으면 true.</summary>
public bool Save(string? remoteUrl, string? userName, string? token)
{
var target = GitCredentialKey.For(remoteUrl);
if (target is null || string.IsNullOrEmpty(token)) return false;
var blob = Encoding.Unicode.GetBytes(token);
var targetPtr = Marshal.StringToCoTaskMemUni(target);
var userPtr = Marshal.StringToCoTaskMemUni(userName ?? string.Empty);
var blobPtr = Marshal.AllocCoTaskMem(blob.Length);
try
{
Marshal.Copy(blob, 0, blobPtr, blob.Length);
var credential = new CREDENTIAL
{
Type = CRED_TYPE_GENERIC,
TargetName = targetPtr,
CredentialBlobSize = blob.Length,
CredentialBlob = blobPtr,
Persist = CRED_PERSIST_LOCAL_MACHINE,
UserName = userPtr,
};
return CredWrite(ref credential, 0);
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
return false;
}
finally
{
Marshal.FreeCoTaskMem(targetPtr);
Marshal.FreeCoTaskMem(userPtr);
Marshal.FreeCoTaskMem(blobPtr);
}
}
/// <summary>맡겨 둔 계정 · 토큰. 없으면 (null, null).</summary>
public (string? UserName, string? Token) Load(string? remoteUrl)
{
var target = GitCredentialKey.For(remoteUrl);
if (target is null) return (null, null);
var ptr = IntPtr.Zero;
try
{
if (!CredRead(target, CRED_TYPE_GENERIC, 0, out ptr)) return (null, null);
var credential = Marshal.PtrToStructure<CREDENTIAL>(ptr);
var user = credential.UserName == IntPtr.Zero ? null : Marshal.PtrToStringUni(credential.UserName);
var token = credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize <= 0
? null
: Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / 2);
return (user, token);
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
return (null, null);
}
finally
{
if (ptr != IntPtr.Zero) CredFree(ptr);
}
}
/// <summary>맡겨 둔 것을 지운다. 지웠으면 true(없었으면 false).</summary>
public bool Forget(string? remoteUrl)
{
var target = GitCredentialKey.For(remoteUrl);
if (target is null) return false;
try
{
return CredDelete(target, CRED_TYPE_GENERIC, 0);
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
return false;
}
}
}
}

View File

@ -58,6 +58,7 @@ namespace MergeHub.Services
RemoteName = string.IsNullOrWhiteSpace(Settings.DefaultRemoteName) ? "origin" : Settings.DefaultRemoteName; RemoteName = string.IsNullOrWhiteSpace(Settings.DefaultRemoteName) ? "origin" : Settings.DefaultRemoteName;
RemoteUrl = Settings.GiteaUrl; RemoteUrl = Settings.GiteaUrl;
UserName = Settings.GiteaUserName; UserName = Settings.GiteaUserName;
LoadToken(); // 이 서버에 맡겨 둔 토큰이 있으면 가져온다(11차 F-11)
Git.IgnoreWhitespace = Settings.IgnoreWhitespace; Git.IgnoreWhitespace = Settings.IgnoreWhitespace;
Git.WordLevelDiff = Settings.WordLevelDiff; Git.WordLevelDiff = Settings.WordLevelDiff;
@ -140,6 +141,91 @@ namespace MergeHub.Services
/// </summary> /// </summary>
public void SaveSettings() => _settingsService.Save(nameof(MergeHubSettings)); public void SaveSettings() => _settingsService.Save(nameof(MergeHubSettings));
// ================= 원격 주소 · 계정 · 토큰 (11차 F-11) =================
/// <summary>토큰을 맡겨 두는 곳 - 윈도 자격 증명 관리자(사용자 결정). 설정 파일에는 토큰 글자가 남지 않는다.</summary>
private readonly GitCredentialStore _tokens = new GitCredentialStore();
/// <summary>주소 · 계정을 설정에 적는 것을 잠깐 모아 둔다 - 글자 하나마다 파일을 쓰지 않으려고.</summary>
private System.Threading.Timer? _remoteSaveTimer;
partial void OnRemoteUrlChanged(string value)
{
QueueRemoteSave();
LoadToken(); // 서버가 바뀌면 그 서버에 맡겨 둔 토큰으로
}
partial void OnUserNameChanged(string value) => QueueRemoteSave();
private void QueueRemoteSave()
{
_remoteSaveTimer?.Dispose();
_remoteSaveTimer = new System.Threading.Timer(_ => OnUi(SaveRemoteSettings), null, 800, System.Threading.Timeout.Infinite);
}
/// <summary>Git 탭 원격 칸에 적은 주소 · 계정을 설정에 남긴다(11차 F-11 - 껐다 켜도 남는다).</summary>
private void SaveRemoteSettings()
{
var url = RemoteUrl ?? string.Empty;
var user = UserName ?? string.Empty;
if (Settings.GiteaUrl == url && Settings.GiteaUserName == user) return;
Settings.GiteaUrl = url;
Settings.GiteaUserName = user;
SaveSettings();
}
/// <summary>지금 토큰 칸에 든 것이 어느 서버에서 가져온 것인지. 주소를 바꾸면 남의 서버로 보내지 않게 치운다.</summary>
private string? _tokenFrom;
/// <summary>
/// 이 서버에 맡겨 둔 계정 · 토큰을 가져온다. 사람이 직접 적어 둔 토큰은 건드리지 않는다 -
/// 다른 서버에서 가져왔던 토큰만 치운다(다른 서버로 보내면 안 된다).
/// </summary>
public void LoadToken()
{
var key = GitCredentialKey.For(RemoteUrl);
if (_tokenFrom is not null && _tokenFrom != key)
{
Password = string.Empty; // 앞 서버에서 가져온 토큰이다
_tokenFrom = null;
}
HasSavedToken = false;
var (user, token) = _tokens.Load(RemoteUrl);
if (string.IsNullOrEmpty(token)) return;
Password = token!;
_tokenFrom = key;
if (string.IsNullOrWhiteSpace(UserName) && !string.IsNullOrWhiteSpace(user)) UserName = user!;
HasSavedToken = true;
}
/// <summary>원격 일(fetch · pull · push · 클론)이 잘 끝났다 - 그때의 계정 · 토큰을 맡겨 둔다.</summary>
public void RememberToken()
{
if (string.IsNullOrEmpty(Password)) return;
if (!_tokens.Save(RemoteUrl, UserName, Password)) return;
HasSavedToken = true;
_tokenFrom = GitCredentialKey.For(RemoteUrl);
AppendLog($"토큰을 윈도 자격 증명 관리자에 저장했습니다({GitCredentialKey.For(RemoteUrl)}). 다음에 켤 때 다시 넣지 않아도 됩니다.");
}
/// <summary>맡겨 둔 토큰을 지운다. 화면의 토큰 칸도 비운다.</summary>
public bool ForgetToken()
{
var had = _tokens.Forget(RemoteUrl);
Password = string.Empty;
HasSavedToken = false;
_tokenFrom = null;
if (had) AppendLog($"저장한 토큰을 지웠습니다({GitCredentialKey.For(RemoteUrl)}).");
return had;
}
/// <summary>이 서버의 토큰을 맡겨 두었는지. 화면의 안내 · [저장한 토큰 지우기] 단추가 본다.</summary>
[ObservableProperty] private bool _hasSavedToken;
/// <summary>저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다.</summary> /// <summary>저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다.</summary>
public event Action? Refreshed; public event Action? Refreshed;
@ -295,8 +381,10 @@ namespace MergeHub.Services
_logger.E($"[{operation}] 실패", ex); _logger.E($"[{operation}] 실패", ex);
OnUi(() => OnUi(() =>
{ {
AppendLog($"[{operation}] 실패: {ex.Message}"); // 인증이 필요하다는 영어 말은 무엇을 하라는 말로 바꿔 준다(11차 F-11).
MessageUtil.ShowErrorToast($"{operation} 실패: {ex.Message}"); var said = GitErrors.Friendly(ex.Message) is { } friendly ? $"{friendly} ({ex.Message})" : ex.Message;
AppendLog($"[{operation}] 실패: {said}");
MessageUtil.ShowErrorToast($"{operation} 실패: {said}");
}); });
return false; return false;
} }

View File

@ -201,11 +201,15 @@ namespace MergeHub.ViewModels
private bool CanClone() => !string.IsNullOrWhiteSpace(_workspace.RemoteUrl); private bool CanClone() => !string.IsNullOrWhiteSpace(_workspace.RemoteUrl);
[RelayCommand(CanExecute = nameof(CanClone))] [RelayCommand(CanExecute = nameof(CanClone))]
private Task CloneAsync() => _workspace.RunAndRefreshAsync("클론", () => private async Task CloneAsync()
{ {
_workspace.ApplyCredentials(); var ok = await _workspace.RunAndRefreshAsync("클론", () =>
_workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath); {
}); _workspace.ApplyCredentials();
_workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath);
});
if (ok) _workspace.RememberToken(); // 된 토큰만 맡긴다(11차 F-11)
}
[RelayCommand(CanExecute = nameof(IsOpen))] [RelayCommand(CanExecute = nameof(IsOpen))]
private Task AddRemoteAsync() => _workspace.RunAndRefreshAsync("원격 추가", () => private Task AddRemoteAsync() => _workspace.RunAndRefreshAsync("원격 추가", () =>
@ -224,12 +228,27 @@ namespace MergeHub.ViewModels
() => _workspace.Git.RemoveRemote(_workspace.RemoteName)); () => _workspace.Git.RemoveRemote(_workspace.RemoteName));
[RelayCommand(CanExecute = nameof(IsOpen))] [RelayCommand(CanExecute = nameof(IsOpen))]
private Task FetchAsync() => _workspace.RunAndRefreshAsync("fetch", () => private async Task FetchAsync()
{ {
_workspace.ApplyCredentials(); var ok = await _workspace.RunAndRefreshAsync("fetch", () =>
_workspace.Git.Fetch(_workspace.RemoteName); {
_workspace.MarkFetched(); _workspace.ApplyCredentials();
}); _workspace.Git.Fetch(_workspace.RemoteName);
_workspace.MarkFetched();
});
if (ok) _workspace.RememberToken();
}
private bool CanForgetToken() => _workspace.HasSavedToken || !string.IsNullOrEmpty(_workspace.Password);
/// <summary>맡겨 둔 토큰을 지운다(11차 F-11). 다른 사람에게 PC 를 넘길 때 · 토큰을 새로 받았을 때.</summary>
[RelayCommand]
private void ForgetToken()
{
var had = _workspace.ForgetToken();
MessageUtil.ShowSuccessToast(had ? "저장한 토큰을 지웠습니다." : "저장해 둔 토큰이 없습니다. 토큰 칸만 비웠습니다.");
RaiseCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(IsOpen))] [RelayCommand(CanExecute = nameof(IsOpen))]
private Task PullAsync() private Task PullAsync()
@ -237,7 +256,12 @@ namespace MergeHub.ViewModels
// 목록은 화면 스레드의 것이라 작업에 들어가기 전에 읽어 둔다. // 목록은 화면 스레드의 것이라 작업에 들어가기 전에 읽어 둔다.
var tracked = _workspace.Branches.FirstOrDefault(b => b.IsCurrentHead)?.TrackedBranch; var tracked = _workspace.Branches.FirstOrDefault(b => b.IsCurrentHead)?.TrackedBranch;
return _workspace.RunAndRefreshAsync("pull", () => return PullCoreAsync(tracked);
}
private async Task PullCoreAsync(string? tracked)
{
var ok = await _workspace.RunAndRefreshAsync("pull", () =>
{ {
_workspace.ApplyCredentials(); _workspace.ApplyCredentials();
var result = _workspace.Git.Pull(_workspace.RemoteName); var result = _workspace.Git.Pull(_workspace.RemoteName);
@ -247,14 +271,19 @@ namespace MergeHub.ViewModels
// 예전에는 여기서 아무것도 안 해 주석만 다른 충돌까지 사람에게 물었고, 알림도 커밋 메시지도 없었다. // 예전에는 여기서 아무것도 안 해 주석만 다른 충돌까지 사람에게 물었고, 알림도 커밋 메시지도 없었다.
AfterMerge(result, MergeNames.PullMergeMessage(tracked, _workspace.CurrentBranch)); AfterMerge(result, MergeNames.PullMergeMessage(tracked, _workspace.CurrentBranch));
}); });
if (ok) _workspace.RememberToken();
} }
[RelayCommand(CanExecute = nameof(IsOpen))] [RelayCommand(CanExecute = nameof(IsOpen))]
private Task PushAsync() => _workspace.RunAndRefreshAsync("push", () => private async Task PushAsync()
{ {
_workspace.ApplyCredentials(); var ok = await _workspace.RunAndRefreshAsync("push", () =>
_workspace.Git.Push(remoteName: _workspace.RemoteName); {
}); _workspace.ApplyCredentials();
_workspace.Git.Push(remoteName: _workspace.RemoteName);
});
if (ok) _workspace.RememberToken();
}
[RelayCommand(CanExecute = nameof(IsOpen))] [RelayCommand(CanExecute = nameof(IsOpen))]
private Task RefreshAsync() => _workspace.RefreshAsync(); private Task RefreshAsync() => _workspace.RefreshAsync();
@ -1093,6 +1122,7 @@ namespace MergeHub.ViewModels
private void RaiseCanExecuteChanged() private void RaiseCanExecuteChanged()
{ {
ReopenConflictCommand.NotifyCanExecuteChanged(); ReopenConflictCommand.NotifyCanExecuteChanged();
ForgetTokenCommand.NotifyCanExecuteChanged();
OpenCommand.NotifyCanExecuteChanged(); OpenCommand.NotifyCanExecuteChanged();
CloneCommand.NotifyCanExecuteChanged(); CloneCommand.NotifyCanExecuteChanged();
AddRemoteCommand.NotifyCanExecuteChanged(); AddRemoteCommand.NotifyCanExecuteChanged();

View File

@ -122,11 +122,17 @@
<TextBlock Grid.Column="0" Text="Gitea 계정" Style="{StaticResource Muted}" Margin="0,0,6,0"/> <TextBlock Grid.Column="0" Text="Gitea 계정" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" Text="{Binding Workspace.UserName, UpdateSourceTrigger=PropertyChanged}"/> <TextBox Grid.Column="1" Text="{Binding Workspace.UserName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Column="2" Text="토큰/암호" Style="{StaticResource Muted}" Margin="8,0,6,0"/> <TextBlock Grid.Column="2" Text="토큰/암호" Style="{StaticResource Muted}" Margin="8,0,6,0"/>
<PasswordBox Grid.Column="3" PasswordChanged="Password_Changed" <PasswordBox x:Name="TokenBox" Grid.Column="3" PasswordChanged="Password_Changed"
Background="{DynamicResource Ubisam.Brush.Input.Background}" Background="{DynamicResource Ubisam.Brush.Input.Background}"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}" Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" Padding="4,3"/> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" Padding="4,3"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" HorizontalAlignment="Right"> <StackPanel Grid.Column="4" Orientation="Horizontal" HorizontalAlignment="Right">
<!-- 11차 F-11: 토큰은 윈도 자격 증명 관리자에 맡긴다. 맡겨 둔 것이 있을 때만 보인다. -->
<TextBlock Text="저장됨" Style="{StaticResource Muted}" FontSize="11" VerticalAlignment="Center" Margin="0,0,6,0"
ToolTip="이 서버의 토큰을 윈도 자격 증명 관리자에 맡겨 두었습니다. 설정 파일에는 토큰이 남지 않습니다"
Visibility="{Binding Workspace.HasSavedToken, Converter={StaticResource BoolToVis}}"/>
<Button Content="토큰 지우기" Command="{Binding ForgetTokenCommand}" Margin="0,0,8,0"
ToolTip="맡겨 둔 토큰을 지우고 토큰 칸을 비웁니다"/>
<Button Content="Fetch" Command="{Binding FetchCommand}"/> <Button Content="Fetch" Command="{Binding FetchCommand}"/>
<Button Content="Pull" Command="{Binding PullCommand}"/> <Button Content="Pull" Command="{Binding PullCommand}"/>
<Button Content="Push" Command="{Binding PushCommand}"/> <Button Content="Push" Command="{Binding PushCommand}"/>

View File

@ -17,6 +17,15 @@ namespace MergeHub.Views
// 다른 탭에 있는 동안 파일 비교 탭에서 고른 커밋의 내용을, 이 탭이 다시 보일 때 그린다(10차 B-1). // 다른 탭에 있는 동안 파일 비교 탭에서 고른 커밋의 내용을, 이 탭이 다시 보일 때 그린다(10차 B-1).
IsVisibleChanged += (_, e) => { if (e.NewValue is true) ViewModel?.ShowPendingDiff(); }; IsVisibleChanged += (_, e) => { if (e.NewValue is true) ViewModel?.ShowPendingDiff(); };
// 맡겨 둔 토큰을 토큰 칸에 보인다(11차 F-11).
DataContextChanged += (_, e) =>
{
if (e.OldValue is GitViewModel old) old.Workspace.PropertyChanged -= OnWorkspaceChanged;
if (e.NewValue is GitViewModel now) now.Workspace.PropertyChanged += OnWorkspaceChanged;
ShowToken();
};
Loaded += (_, _) => ShowToken();
// 이력에서 다른 커밋을 누르면 파일 비교 탭도 따라간다. 거기 저장 안 한 편집이 있으면 선택이 바뀌기 전에 묻고, // 이력에서 다른 커밋을 누르면 파일 비교 탭도 따라간다. 거기 저장 안 한 편집이 있으면 선택이 바뀌기 전에 묻고,
// [취소] 면 누름을 막아 선택이 그대로다(10차 B-1). // [취소] 면 누름을 막아 선택이 그대로다(10차 B-1).
HistoryList.PreviewMouseLeftButtonDown += (_, e) => HistoryList.PreviewMouseLeftButtonDown += (_, e) =>
@ -77,6 +86,11 @@ namespace MergeHub.Views
private GitViewModel? ViewModel => DataContext as GitViewModel; private GitViewModel? ViewModel => DataContext as GitViewModel;
private void OnWorkspaceChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MergeHub.Services.GitWorkspace.Password)) ShowToken();
}
private void BrowseFolder_Click(object sender, RoutedEventArgs e) private void BrowseFolder_Click(object sender, RoutedEventArgs e)
{ {
var vm = ViewModel; var vm = ViewModel;
@ -114,9 +128,28 @@ namespace MergeHub.Views
private void Password_Changed(object sender, RoutedEventArgs e) private void Password_Changed(object sender, RoutedEventArgs e)
{ {
if (_fillingToken) return;
if (sender is PasswordBox box && ViewModel is { } vm) vm.Workspace.Password = box.Password; if (sender is PasswordBox box && ViewModel is { } vm) vm.Workspace.Password = box.Password;
} }
/// <summary>토큰 칸을 프로그램이 채우는 중. 사람이 친 것으로 되돌아오지 않게 막는다.</summary>
private bool _fillingToken;
/// <summary>
/// 맡겨 둔 토큰(11차 F-11)을 토큰 칸에 보인다. PasswordBox 는 바인딩이 안 돼 코드로 넣는다 -
/// 켤 때 한 번, 그리고 원격 주소가 바뀌어 다른 서버의 토큰을 가져왔을 때.
/// </summary>
private void ShowToken()
{
if (ViewModel is not { } vm) return;
var token = vm.Workspace.Password ?? string.Empty;
if (TokenBox.Password == token) return;
_fillingToken = true;
try { TokenBox.Password = token; }
finally { _fillingToken = false; }
}
private void Log_TextChanged(object sender, TextChangedEventArgs e) private void Log_TextChanged(object sender, TextChangedEventArgs e)
{ {
if (sender is TextBox box) box.ScrollToEnd(); if (sender is TextBox box) box.ScrollToEnd();