commit-regroup
로컬에만 있는(아직 push 안 한) 잘게 쪼개진 여러 커밋을 논리 단위 몇 개로 다시 묶는다. rebase 재정렬 대신 soft-reset + path 재배치로 충돌 없이 재구성하고, 최종 트리가 원본과 byte-identical인지 검증해 코드 무변경을 보장한다. "커밋 묶어줘", "로컬 커밋 grouping", "커밋 정리해줘", "커밋 합쳐줘", "
SKILL.mdskills/commit-regroup/SKILL.md
GitHubCommit Regroup
origin/<branch>..HEAD의 잘게 나뉜 로컬 커밋을 논리 단위 몇 개로 다시 묶는 스킬.
이미 끝난 작업의 히스토리만 재구성한다 — 코드 변경 내용은 1바이트도 바꾸지 않는다.
언제 이 스킬인가
- 로컬 커밋이 remote보다 여러 개 앞서 있고, 그대로 push하면 log가 지저분할 때.
- worktree squash가 아니라 develop/feature에 직접 쌓인 잡다한 커밋을 push 전에 정리할 때.
- 구분: 메시지 후보만 필요하면 commit-msg. worktree→develop squash 시점 판단은 worktree-lifecycle.
- 대상은 아직 push 안 한 로컬 커밋만. push된 커밋 rewrite는 공유 히스토리를 깨므로 이 스킬 밖이다.
핵심 결정: soft-reset 재배치 > rebase 재정렬
두 방법이 있고, 대부분 soft-reset이 이긴다:
- rebase 재정렬/fixup: 커밋별 diff는 보존하되 같은 feature 커밋을 인접하게 옮겨야 한다. 생성 파일(
openapi.json, lockfile, route tree)·여러 feature가 공유한 파일에서 충돌이 다발한다. 환경에 따라git rebase -i자체가 막혀 있기도 하다. - soft-reset + path 재배치 (기본 추천):
git reset <base>로 최종 트리만 남기고, 변경 파일을 버킷별로 다시 add+commit. 재정렬이 없어 충돌 0. 비용은 (a) 원본 메시지 폐기·재작성, (b) 한 파일을 여러 feature가 건드렸으면 한 커밋에만 귀속.
feature가 서로의 파일을 리팩터(리네임 등)하며 얽혀 있을수록 soft-reset이 유일하게 깨끗하다 — rebase는 그 지점에서 코드 충돌을 만든다. 커밋별 diff 보존이나 bisect 가능성이 꼭 필요할 때만 rebase를 고려한다.
불변식: 최종 트리 = 원본 트리
재구성 전후 트리 해시가 같아야 한다. 이게 "코드는 안 건드리고 묶기만 했다"의 유일한 증거다.
git rev-parse 'HEAD^{tree}' # 시작 전 기록
# ... 재구성 후 ...
git rev-parse 'HEAD^{tree}' # 같아야 함
다르면 재구성이 잘못된 것이다. 커밋을 남겨두고 git diff --stat <orig-head> HEAD로 원인을 본 뒤, git reset --hard <orig-head>(원본은 reflog에 보존)로 되돌리고 분류를 고친다. scripts/regroup.sh는 이 검증을 자동으로 하고 실패 시 롤백 명령을 출력한다.
워크플로우
- 상태 확인:
git status --short --branch,git log --oneline origin/<branch>..HEAD. tracked unstaged/staged 변경이 있으면 먼저 정리한다(untracked은 무방 — 건드리지 않는다). - 버킷 설계: 커밋을
git show --stat으로 훑어 논리 트랙으로 나눈다. 트랙 1개 = 최종 커밋 1개. 의도/추천을 사용자와 합의하고, 히스토리 rewrite라 실행 전 한 번 확인받는다. - 파일 분류 (reset 전 dry-run):
git diff --name-status -M origin/<branch> HEAD의 모든 path를 정확히 한 버킷에 배정한다. 리네임R old new는 old·new 둘 다 같은 버킷에. first-match-wins로 구체 패턴을 광범위 패턴보다 위에 둔다. UNCLASSIFIED 0과 전수 커버리지를 reset 전에 확인한다 — reset 후에 누락을 발견하면 트리가 깨진다. - 실행 + 검증:
scripts/regroup.sh에 base·분류 매핑·버킷별 커밋 메시지(순서=커밋 순서)를 넘긴다. 스크립트가 완전성·precondition을 검사하고 reset → 버킷별 add+commit → 트리 동일성 검증까지 한다. - 보고: 새 log + 트리 PASS + 롤백 지점(
<orig-head>)을 알린다. push는 하지 않는다 — CI/배포 트리거라 사용자 몫이다.
분류기 패턴
버킷 규칙은 매 작업마다 실제 파일 기준으로 새로 짠다(고정하지 않는다). 일회용 분류기로 매핑을 뽑는 게 빠르고 검증 가능하다:
# 각 path → bucket. 구체 패턴을 위에, 광범위 패턴을 아래에 (first-match-wins).
classify() { case "$1" in
*/pose-logs/*) echo featureA ;; # 광범위 dir보다 먼저 매칭돼야 함
*/features/api-keys/*) echo featureB ;;
apps/api/openapi.json) echo featureA ;; # 생성 파일 → 우세 버킷에 통째로
package.json|turbo.json) echo misc ;;
*) echo UNCLASSIFIED ;;
esac; }
git diff --name-status -M origin/<branch> HEAD | awk -F'\t' '
$1 ~ /^R/ { print $2; print $3; next } # rename: old+new 둘 다
{ print $2 }' | while read -r p; do printf '%s\t%s\n' "$(classify "$p")" "$p"; done
UNCLASSIFIED가 0인지 먼저 확인하고, bucket<TAB>path 매핑과 bucket<TAB>subject 메시지 파일을 scripts/regroup.sh로 넘긴다(사용법은 스크립트 상단 주석).
스크립트 없이 수동: git rev-parse 'HEAD^{tree}' 기록 → git reset <base> → 버킷별 git add -A -- <paths> + git commit → 트리 해시 재비교.
Gotchas
- 중간 커밋은 단독 빌드 불가: cumulative diff를 path로 쪼개면 마지막 커밋(또는 전체) 에서만 트리가 완결된다. 한 커밋이 아직 없는 다른 커밋의 심볼을 참조할 수 있다.
git log정리엔 무해하나git bisect대상이면 의미 있는 제약 — 보고에 명시한다. - 공유·생성 파일 귀속:
openapi.json·lockfile·todo.md·여러 feature가 만진 컴포넌트는 path로 못 쪼갠다. 최종 블롭을 우세 버킷에 통째로 넣고, 커밋 attribution이 살짝 어긋남을 기록한다(트리는 항상 정확). hunk 단위로 쪼개려git add -p에 의존하지 말 것 — 비용 대비 이득이 작고 일부 환경에선 interactive가 막힌다. - 리네임은 old+new 한 쌍:
R old new에서 old(삭제)와 new(추가)가 다른 버킷에 가면 rename이 깨진다. 분류 규칙을 양쪽에 적용해 같은 버킷으로 보낸다.git add -A -- <path>가 M/A/D/rename을 일괄 처리한다 —git add <path>는 삭제를 못 잡으니-A를 쓴다. - untracked은 그대로 둔다: reset은 untracked을 안 건드린다.
git add .대신 버킷 path만 명시해 미추적 파일·다른 사람이 미리 staged한 변경이 휩쓸려 들어가지 않게 한다. - 원본 보존 = 롤백: reset 전 HEAD는 reflog/objects에 남는다. 잘못되면
git reset --hard <orig-head>로 원복. 스크립트는 이 SHA를 보고에 남긴다. - base 검증: base는 보통
origin/<branch>. base가 HEAD의 ancestor인지 확인하고(스크립트가 강제), 이미 push된 지점 너머로는 내려가지 않는다.