@tuzi-ince/hi-loop 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -119
- package/bin/hi-loop.js +46 -3
- package/bin/setup.js +54 -1
- package/package.json +2 -1
- package/skills/hi-loop/SKILL.md +117 -0
- package/src/cli-options.js +6 -1
- package/src/config.js +112 -0
- package/src/loop.js +25 -7
- package/src/mcp-server.js +21 -7
- package/src/stages.js +31 -0
- package/src/vcs.js +167 -0
package/src/vcs.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 브랜치·커밋 메커니즘 (FR-16) — 엔진이 소유하는 git 조작.
|
|
3
|
+
*
|
|
4
|
+
* 철학: **엔진은 메커니즘(git 실행)을, LLM 은 산문(좋은 커밋 메시지)을 소유한다.**
|
|
5
|
+
* 그래서 여기 함수들은 순수 git 이고, 메시지는 인자로 받거나(스킬이 지어 넘김) 없으면
|
|
6
|
+
* goal 기반 휴리스틱으로 만든다. checkpoint.js 의 git 패턴(실패 시 null, 비-git skip)을 따른다.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { isGitRepo } from './checkpoint.js';
|
|
10
|
+
|
|
11
|
+
/** `{ ok, out }`. 실패(비-0 exit)면 ok:false, out 은 stdout+stderr. */
|
|
12
|
+
function git(args, cwd) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
execFile('git', args, { cwd, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
15
|
+
const out = `${String(stdout ?? '')}${String(stderr ?? '')}`.trim();
|
|
16
|
+
resolve({ ok: !err, out });
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 현재 브랜치명. detached/비-git 이면 null. */
|
|
22
|
+
export async function currentBranch(cwd) {
|
|
23
|
+
const r = await git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
24
|
+
if (!r.ok) return null;
|
|
25
|
+
const name = r.out.trim();
|
|
26
|
+
return name && name !== 'HEAD' ? name : null; // 'HEAD' = detached
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 이 브랜치가 보호 대상인가. */
|
|
30
|
+
export function isProtected(branch, protectedBranches) {
|
|
31
|
+
if (!branch) return false;
|
|
32
|
+
const list = Array.isArray(protectedBranches) ? protectedBranches : [];
|
|
33
|
+
return list.includes(branch);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** goal → 브랜치 slug. 소문자 kebab, 안전 문자만, 앞부분만. 비면 'change'. */
|
|
37
|
+
export function slugify(goal) {
|
|
38
|
+
const base = String(goal ?? '')
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9가-힣]+/g, '-') // 공백·기호 → 하이픈 (한글은 보존)
|
|
41
|
+
.replace(/^-+|-+$/g, '')
|
|
42
|
+
.slice(0, 40)
|
|
43
|
+
.replace(/-+$/g, '');
|
|
44
|
+
return base || 'change';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** feature 브랜치로 생성/전환. 이미 있으면 전환. 반환 `{ ok, branch, created, reason }`. */
|
|
48
|
+
export async function createBranch(cwd, name) {
|
|
49
|
+
const branch = String(name ?? '').trim();
|
|
50
|
+
if (!branch) return { ok: false, reason: '브랜치 이름이 비어 있습니다.' };
|
|
51
|
+
const exists = (await git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], cwd)).ok;
|
|
52
|
+
const r = exists ? await git(['switch', branch], cwd) : await git(['switch', '-c', branch], cwd);
|
|
53
|
+
if (!r.ok) return { ok: false, reason: r.out || 'git switch 실패' };
|
|
54
|
+
return { ok: true, branch, created: !exists };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 워킹트리 전체를 스테이징하고 커밋. 변경 없으면 실패로 돌려준다. 반환 `{ ok, sha, reason }`. */
|
|
58
|
+
export async function commitAll(cwd, message) {
|
|
59
|
+
const msg = String(message ?? '').trim();
|
|
60
|
+
if (!msg) return { ok: false, reason: '커밋 메시지가 비어 있습니다.' };
|
|
61
|
+
await git(['add', '-A'], cwd);
|
|
62
|
+
// 스테이징 후에도 변경이 없으면 커밋할 게 없다 — 조용히 실패로 신호.
|
|
63
|
+
const staged = await git(['diff', '--cached', '--quiet'], cwd);
|
|
64
|
+
if (staged.ok) return { ok: false, reason: '커밋할 변경이 없습니다.' };
|
|
65
|
+
const c = await git(['commit', '-m', msg], cwd);
|
|
66
|
+
if (!c.ok) return { ok: false, reason: c.out || 'git commit 실패' };
|
|
67
|
+
const sha = await git(['rev-parse', 'HEAD'], cwd);
|
|
68
|
+
return { ok: true, sha: sha.ok ? sha.out.trim() : null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CONVENTIONAL = /^(\w+)(\(.+\))?!?:\s/;
|
|
72
|
+
|
|
73
|
+
/** 최근 커밋 제목들을 보고 Conventional Commits 관행인지 추정. 'conventional' | 'plain'. */
|
|
74
|
+
export async function detectCommitStyle(cwd) {
|
|
75
|
+
const r = await git(['log', '-20', '--pretty=%s'], cwd);
|
|
76
|
+
if (!r.ok || !r.out) return 'plain'; // 커밋 이력이 없으면 평문
|
|
77
|
+
const subjects = r.out.split('\n').filter(Boolean);
|
|
78
|
+
const conv = subjects.filter((s) => CONVENTIONAL.test(s)).length;
|
|
79
|
+
return conv / subjects.length >= 0.5 ? 'conventional' : 'plain';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** goal 키워드 → conventional type. 확신 없으면 chore. */
|
|
83
|
+
function inferType(goal) {
|
|
84
|
+
const g = String(goal ?? '').toLowerCase();
|
|
85
|
+
if (/(추가|만들|신규|기능|feat|add|implement)/.test(g)) return 'feat';
|
|
86
|
+
if (/(고치|버그|수정|오류|fix|bug)/.test(g)) return 'fix';
|
|
87
|
+
if (/(리팩터|정리|refactor|cleanup)/.test(g)) return 'refactor';
|
|
88
|
+
if (/(문서|docs|readme)/.test(g)) return 'docs';
|
|
89
|
+
if (/(테스트|test)/.test(g)) return 'test';
|
|
90
|
+
return 'chore';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** goal 첫 줄을 커밋 제목 길이로 다듬는다. */
|
|
94
|
+
function firstLine(goal) {
|
|
95
|
+
const line = String(goal ?? '').split('\n')[0].trim();
|
|
96
|
+
return line.length > 72 ? `${line.slice(0, 69)}...` : line;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 메시지가 안 주어졌을 때 goal 로 커밋 메시지를 만든다.
|
|
101
|
+
* styleConfig: 'korean'=goal 그대로 | 'conventional'=type 접두 | 'match-log'=repo 관행 따름.
|
|
102
|
+
*/
|
|
103
|
+
export async function buildCommitMessage({ goal, styleConfig = 'match-log', cwd }) {
|
|
104
|
+
const subject = firstLine(goal) || 'update';
|
|
105
|
+
if (styleConfig === 'korean') return subject;
|
|
106
|
+
const conventional =
|
|
107
|
+
styleConfig === 'conventional' || (styleConfig === 'match-log' && (await detectCommitStyle(cwd)) === 'conventional');
|
|
108
|
+
return conventional ? `${inferType(goal)}: ${subject}` : subject;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 주입 가능한 브랜치 포트 (NFR-3). 보호 브랜치에 있을 때만 feature 로 분기한다.
|
|
113
|
+
* 반환: `{ branched, branch, reason }`.
|
|
114
|
+
*/
|
|
115
|
+
export function makeBrancher() {
|
|
116
|
+
return async ({ cwd, name = null, goal, protectedBranches, logger = () => {} }) => {
|
|
117
|
+
if (!(await isGitRepo(cwd))) return { branched: false, reason: 'non-git' };
|
|
118
|
+
const cur = await currentBranch(cwd);
|
|
119
|
+
if (!isProtected(cur, protectedBranches)) {
|
|
120
|
+
return { branched: false, branch: cur, reason: 'not-protected' };
|
|
121
|
+
}
|
|
122
|
+
const target = (typeof name === 'string' && name.trim()) ? name.trim() : `feature/${slugify(goal)}`;
|
|
123
|
+
const r = await createBranch(cwd, target);
|
|
124
|
+
if (!r.ok) {
|
|
125
|
+
logger(`[hi-loop] ⚠️ 브랜치 분기 실패: ${r.reason}`);
|
|
126
|
+
return { branched: false, reason: r.reason };
|
|
127
|
+
}
|
|
128
|
+
logger(`[hi-loop] 🌱 ${cur} → ${r.branch} (${r.created ? '생성' : '전환'})`);
|
|
129
|
+
return { branched: true, branch: r.branch };
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 브랜치 프리스텝 (FR-16) — loop.js 를 얇게 유지하려 여기 둔다.
|
|
135
|
+
* 분기했으면 브랜치명을, 아니면 null 을 돌려준다.
|
|
136
|
+
*/
|
|
137
|
+
export async function branchPreStep({ brancher, cwd, branch, goal, protectedBranches, logger }) {
|
|
138
|
+
if (branch == null) return null;
|
|
139
|
+
const res = await brancher({
|
|
140
|
+
cwd,
|
|
141
|
+
name: typeof branch === 'string' ? branch : null,
|
|
142
|
+
goal,
|
|
143
|
+
protectedBranches,
|
|
144
|
+
logger,
|
|
145
|
+
});
|
|
146
|
+
return res.branched ? res.branch : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 주입 가능한 커밋 포트 (NFR-3). 메시지가 없으면 goal 로 만든다. 비-git·변경없음이면 조용히 skip.
|
|
151
|
+
* 반환: `{ committed, sha, message, reason }`.
|
|
152
|
+
*/
|
|
153
|
+
export function makeCommitter() {
|
|
154
|
+
return async ({ cwd, message = null, goal, styleConfig = 'match-log', logger = () => {} }) => {
|
|
155
|
+
if (!(await isGitRepo(cwd))) return { committed: false, reason: 'non-git' };
|
|
156
|
+
const msg = (typeof message === 'string' && message.trim())
|
|
157
|
+
? message.trim()
|
|
158
|
+
: await buildCommitMessage({ goal, styleConfig, cwd });
|
|
159
|
+
const r = await commitAll(cwd, msg);
|
|
160
|
+
if (!r.ok) {
|
|
161
|
+
logger(`[hi-loop] · 커밋 skip — ${r.reason}`);
|
|
162
|
+
return { committed: false, message: msg, reason: r.reason };
|
|
163
|
+
}
|
|
164
|
+
logger(`[hi-loop] ✅ 커밋 ${r.sha?.slice(0, 8)} — ${msg}`);
|
|
165
|
+
return { committed: true, sha: r.sha, message: msg };
|
|
166
|
+
};
|
|
167
|
+
}
|