Skip to content

Commit 3b29e85

Browse files
committed
feat: gitdir 조건부 include 설정 지원 (git includeIf 스타일)
전역/프로젝트 설정에 include 규칙 목록 추가: - gitdir 없는 항목은 항상 포함 (범용 공유 베이스 설정) - gitdir 패턴(~ 확장, / 끝은 하위 전체)이 현재 리포 경로와 매칭될 때만 포함 — 디렉터리별 정책을 전역 한 곳에서 관리 - 우선순위: 본문 > 나중 include > 앞 include, 전체적으로는 프로젝트 > preset > 전역 유지 - 안전장치: include 중첩 무시, 원격 preset의 include 무시(보안), 누락 파일 경고 후 진행, 포함 파일도 스키마 자동 마이그레이션
1 parent 5866ad6 commit 3b29e85

7 files changed

Lines changed: 716 additions & 1 deletion

File tree

.commit-checker.schema.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@
4848
}
4949
},
5050

51+
"include": {
52+
"description": "조건부 설정 포함 규칙 목록 (git 의 [includeIf \"gitdir:...\"] 와 유사).\n각 항목의 path 파일을 베이스로 순서대로 병합한 뒤 본문을 그 위에 병합함.\n우선순위: 본문 값 > 나중 include > 앞 include.\n원격 preset 설정 안의 include 는 보안상 무시되며, 포함된 파일 안의 include 도 무시됨 (중첩 금지). 누락 파일은 경고 후 건너뜀.",
53+
"type": "array",
54+
"items": {
55+
"type": "object",
56+
"additionalProperties": false,
57+
"properties": {
58+
"path": {
59+
"description": "포함할 설정 파일 경로. '~' 는 홈 디렉터리로 확장되고, 상대 경로는 include 를 선언한 설정 파일 기준.",
60+
"type": "string",
61+
"examples": ["~/.config/commit-checker/base.yml"]
62+
},
63+
"gitdir": {
64+
"description": "포함 조건이 되는 디렉터리 glob 패턴 (git includeIf gitdir 의미론). 비어있으면 항상 포함.\n'~' 는 홈 디렉터리로 확장되고, '/' 로 끝나면 '**' 가 덧붙어 해당 디렉터리와 하위 전체를 매칭.",
65+
"type": "string",
66+
"examples": ["~/work/"]
67+
}
68+
}
69+
}
70+
},
71+
5172
"comment_language": {
5273
"description": "코드 주석의 언어를 검사하는 설정",
5374
"type": "object",

internal/config/config.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ type Config struct {
3333
// 병합 시 프로젝트 설정 값이 전역/프리셋보다 우선합니다.
3434
Enabled *bool `yaml:"enabled"`
3535

36+
// Include: 조건부 설정 포함 규칙 목록 (git 의 [includeIf "gitdir:..."] 와 유사).
37+
// include 파일들은 순서대로 병합되어 베이스가 되고 그 위에 본문이 병합됩니다.
38+
// 즉 본문 값 > 나중 include > 앞 include 순으로 우선합니다.
39+
// 전역/프로젝트 설정 양쪽에서 동작하며, 원격 preset 안의 include 는 보안상 무시됩니다.
40+
Include []IncludeRule `yaml:"include"`
41+
3642
Preset PresetConfig `yaml:"preset"`
3743
CommentLanguage CommentLanguageConfig `yaml:"comment_language"`
3844
CommitMessage CommitMessageConfig `yaml:"commit_message"`
@@ -87,6 +93,9 @@ func Load(cfgPath string) (*Config, error) {
8793
return nil, formatConfigError(cfgPath, err)
8894
}
8995

96+
// include 해석: include 파일들을 베이스로 깔고 본문을 그 위에 병합.
97+
cfg = *resolveIncludes(&cfg, cfgPath)
98+
9099
// 프리셋 로드: preset.url이 설정된 경우 URL에서 기본 설정을 가져옴.
91100
var presetCfg *Config
92101
if cfg.Preset.URL != "" {
@@ -156,6 +165,12 @@ func loadPresetConfig(preset *PresetConfig) (*Config, error) {
156165
if cfg.Preset.URL != "" {
157166
return nil, fmt.Errorf("preset은 중첩될 수 없습니다 (preset 안에 preset.url 사용 불가): %s", cfg.Preset.URL)
158167
}
168+
// 보안: 원격 프리셋이 로컬 파일을 끌어오지 못하도록 preset 안의 include 는 무시.
169+
if len(cfg.Include) > 0 {
170+
logger.Warn("preset 설정의 include 는 무시됩니다 (원격 설정의 로컬 파일 포함 금지)",
171+
"url", preset.URL)
172+
cfg.Include = nil
173+
}
159174
return &cfg, nil
160175
}
161176

@@ -268,5 +283,6 @@ func loadGlobalConfig() *Config {
268283
logger.Warn("global config parse error, ignoring", "path", globalPath, "error", err)
269284
return nil
270285
}
271-
return &cfg
286+
// include 해석: 프로젝트 설정(Load)과 동일하게 로드 직후 적용.
287+
return resolveIncludes(&cfg, globalPath)
272288
}

internal/config/include.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// include.go: git 의 [includeIf "gitdir:..."] 와 유사한 조건부 설정 포함(include) 처리.
2+
package config
3+
4+
import (
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/zcube/commit-checker/internal/config/schema"
10+
"github.com/zcube/commit-checker/internal/logger"
11+
"github.com/zcube/commit-checker/internal/pathutil"
12+
"gopkg.in/yaml.v3"
13+
)
14+
15+
// resolveIncludes: cfg.Include 규칙을 해석하여 include 파일들을 베이스로 깐 병합 결과를 반환합니다.
16+
// 전역 설정과 프로젝트 설정 양쪽에서 로드 직후 동일하게 호출됩니다.
17+
//
18+
// 병합 의미론:
19+
// - include 는 베이스 제공 역할: 본문(cfg) 값 > include 값.
20+
// - 여러 include 간에는 나중 항목 > 앞 항목.
21+
// - 처리 순서: include 들을 순서대로 병합해 베이스를 만들고, 그 위에 본문을 병합.
22+
// - 전체 우선순위는 기존 유지: 프로젝트(include 처리됨) > preset > 전역(include 처리됨).
23+
//
24+
// 안전장치:
25+
// - gitdir 가 비어있으면 항상 포함, 지정 시 현재 작업 디렉터리(리포 루트)가 패턴과 매칭될 때만 포함.
26+
// - 누락 파일은 Warn 후 건너뜀.
27+
// - 포함된 파일 안의 include 는 무시 (중첩 금지) + Warn.
28+
func resolveIncludes(cfg *Config, cfgPath string) *Config {
29+
if len(cfg.Include) == 0 {
30+
return cfg
31+
}
32+
// 훅은 리포 루트에서 실행되므로 현재 작업 디렉터리를 gitdir 비교 대상으로 사용.
33+
workDir, err := os.Getwd()
34+
if err != nil {
35+
logger.Warn("include: 작업 디렉터리 확인 실패, gitdir 조건 include 를 건너뜀",
36+
"config", cfgPath, "error", err)
37+
workDir = ""
38+
}
39+
40+
var base *Config
41+
for _, rule := range cfg.Include {
42+
if rule.Path == "" {
43+
logger.Warn("include: path 가 비어있는 항목을 건너뜀", "config", cfgPath)
44+
continue
45+
}
46+
if rule.Gitdir != "" && (workDir == "" || !gitdirMatch(rule.Gitdir, workDir)) {
47+
continue // gitdir 조건 비매칭 — 포함하지 않음
48+
}
49+
inc := loadIncludeFile(rule.Path, cfgPath)
50+
if inc == nil {
51+
continue
52+
}
53+
if base == nil {
54+
base = inc
55+
} else {
56+
// 나중 include 가 앞 include 보다 우선
57+
merged := mergeConfigs(base, inc)
58+
base = &merged
59+
}
60+
}
61+
if base == nil {
62+
return cfg
63+
}
64+
// 본문이 include 베이스보다 우선
65+
merged := mergeConfigs(base, cfg)
66+
return &merged
67+
}
68+
69+
// loadIncludeFile: include 대상 설정 파일을 읽어 Config 로 파싱합니다.
70+
// 프로젝트/전역 설정과 동일하게 구버전 스키마 자동 마이그레이션을 적용하며,
71+
// 읽기·파싱 실패 시 Warn 후 nil 을 반환합니다 (Load 전체는 실패하지 않음).
72+
func loadIncludeFile(path, cfgPath string) *Config {
73+
resolved := resolveIncludePath(path, cfgPath)
74+
// 경로는 사용자 본인이 로컬 설정 파일에 직접 적는 값이므로
75+
// path traversal 위협 모델에 해당하지 않음 (G304 전역 제외와 동일한 사유).
76+
data, err := os.ReadFile(resolved) // #nosec G703 G304 -- 사용자 지정 로컬 설정 경로
77+
if err != nil {
78+
logger.Warn("include: 파일을 읽을 수 없어 건너뜀", "path", resolved, "error", err)
79+
return nil
80+
}
81+
82+
// 구 버전 스키마 감지: 현재 스키마로 파싱 실패 시 자동 마이그레이션 시도.
83+
ver := schema.DetectVersion(data)
84+
if ver != schema.VersionCurrent && ver != schema.VersionUnknown {
85+
result, migErr := schema.Migrate(data)
86+
if migErr == nil {
87+
data = result.Data
88+
} else {
89+
logger.Warn("include config auto-migration failed, proceeding with original",
90+
"path", resolved, "error", migErr)
91+
}
92+
}
93+
94+
var inc Config
95+
if err := yaml.Unmarshal(data, &inc); err != nil {
96+
logger.Warn("include: YAML 파싱 실패로 건너뜀", "path", resolved, "error", err)
97+
return nil
98+
}
99+
// 중첩 include 금지: 포함된 파일 안의 include 는 무시.
100+
if len(inc.Include) > 0 {
101+
logger.Warn("include: 중첩 include 는 지원하지 않아 무시함", "path", resolved)
102+
inc.Include = nil
103+
}
104+
return &inc
105+
}
106+
107+
// resolveIncludePath: include path 의 '~' 를 홈 디렉터리로 확장하고,
108+
// 상대 경로는 include 를 선언한 설정 파일 기준으로 해석합니다 (git include 와 동일).
109+
func resolveIncludePath(path, cfgPath string) string {
110+
p := expandTilde(path)
111+
if !filepath.IsAbs(p) {
112+
p = filepath.Join(filepath.Dir(cfgPath), p)
113+
}
114+
return filepath.Clean(p)
115+
}
116+
117+
// gitdirMatch: git 의 includeIf "gitdir:" 의미론으로 workDir 가 패턴과 매칭되는지 확인합니다.
118+
// - '~' 는 홈 디렉터리로 확장.
119+
// - 패턴이 '/' 로 끝나면 '**' 를 덧붙여 해당 디렉터리와 하위 전체를 매칭 (git 과 동일).
120+
// - macOS 의 /tmp → /private/tmp 류 불일치를 막기 위해 양쪽에 심볼릭 링크 해석을 적용.
121+
func gitdirMatch(pattern, workDir string) bool {
122+
dirSuffix := strings.HasSuffix(pattern, "/")
123+
p := filepath.ToSlash(expandTilde(pattern))
124+
if dirSuffix {
125+
p = strings.TrimSuffix(p, "/") + "/**"
126+
}
127+
p = resolveSymlinkPrefix(p)
128+
129+
wd := workDir
130+
if r, err := filepath.EvalSymlinks(wd); err == nil {
131+
wd = r
132+
}
133+
return pathutil.MatchPath(filepath.ToSlash(wd), p)
134+
}
135+
136+
// resolveSymlinkPrefix: 패턴에서 glob 메타문자(*, ?, []) 이전까지의 리터럴 디렉터리
137+
// prefix 에 filepath.EvalSymlinks 를 적용합니다. prefix 가 존재하지 않거나 해석에
138+
// 실패하면 패턴을 그대로 반환합니다 (best-effort).
139+
func resolveSymlinkPrefix(pattern string) string {
140+
segs := strings.Split(pattern, "/")
141+
lit := 0
142+
for ; lit < len(segs); lit++ {
143+
if strings.ContainsAny(segs[lit], "*?[") {
144+
break
145+
}
146+
}
147+
if lit == 0 {
148+
return pattern
149+
}
150+
prefix := strings.Join(segs[:lit], "/")
151+
if prefix == "" {
152+
// 패턴이 "/**" 처럼 루트 직후 glob 으로 시작하는 경우
153+
return pattern
154+
}
155+
resolved, err := filepath.EvalSymlinks(prefix)
156+
if err != nil {
157+
return pattern
158+
}
159+
rest := segs[lit:]
160+
if len(rest) == 0 {
161+
return filepath.ToSlash(resolved)
162+
}
163+
return filepath.ToSlash(resolved) + "/" + strings.Join(rest, "/")
164+
}
165+
166+
// expandTilde: 경로 선두의 '~' 또는 '~/' 를 사용자 홈 디렉터리로 확장합니다.
167+
// 홈 디렉터리를 알 수 없으면 원본을 그대로 반환합니다.
168+
func expandTilde(p string) string {
169+
if p == "~" {
170+
if home, err := os.UserHomeDir(); err == nil {
171+
return home
172+
}
173+
return p
174+
}
175+
if strings.HasPrefix(p, "~/") {
176+
if home, err := os.UserHomeDir(); err == nil {
177+
return filepath.Join(home, p[2:])
178+
}
179+
}
180+
return p
181+
}

0 commit comments

Comments
 (0)