diff --git a/docs/HARNESS_SYNC.md b/docs/HARNESS_SYNC.md index adcb9b2..beeb6f7 100644 --- a/docs/HARNESS_SYNC.md +++ b/docs/HARNESS_SYNC.md @@ -20,7 +20,7 @@ GitHub App 접근 권한은 해당 레포에서 GitHub API를 호출할 수 있 - `.codex/skills/**` (참고 문서·스크립트 포함) - `.claude/skills/**` (참고 문서·스크립트 포함) - `.agent/skills/**` (참고 문서·스크립트 포함) -- `.gitattributes` +- `.gitattributes` (Git LFS 등 저장소 고유 규칙을 보존하고 하네스 관리 블록만 갱신) - `.github/ISSUE_TEMPLATE/{01-feat,02-fix,03-chore,04-refactor}.yml` - `.github/pull_request_template.md` @@ -28,6 +28,23 @@ GitHub App 설치 토큰은 GitHub Actions workflow 파일을 만들 수 없으 `AGENTS.md` 또는 `CLAUDE.md`가 이미 있는 대상 레포는 해당 파일을 덮어쓰지 않아요. `framework-collaboration-harness` 관리 마커 사이의 섹션만 갱신하므로 제품 고유 지시는 유지돼요. +`.gitattributes`는 Git 속성 파일에 맞는 `#` 주석 마커를 사용해요. 로컬 적용과 GitHub App 모두 아래 블록만 갱신하고, 블록 밖의 LFS 규칙·주석·빈 줄과 상대 순서는 보존해요. + +```gitattributes +# framework-collaboration-harness:start +- text=auto eol=lf + +*.md text eol=lf linguist-detectable +*.yaml text eol=lf linguist-detectable +*.yml text eol=lf linguist-detectable +*.json text eol=lf linguist-detectable +# framework-collaboration-harness:end +``` + +마커가 없는 기존 파일에서는 이전에 배포한 하네스 규칙 다섯 줄과 정확히 일치하는 줄만 제거하고, 첫 일치 위치에 관리 블록을 넣어요. 공백이나 속성이 다른 규칙은 저장소 고유 내용으로 유지하며, 빈 줄도 삭제하지 않아요. 이전 규칙 목록은 `sync/attributes.ts`에 고정하여 원본 규칙이 바뀌어도 이전을 지원해요. 일치하는 줄이 없으면 기존 내용 뒤에 블록을 추가하고, 파일이 없으면 블록으로 새 파일을 만들어요. + +재실행은 동일한 내용을 만들어요. 모든 동기화 대상 파일이 같으면 GitHub App은 커밋·브랜치·PR을 만들지 않아요. Actions 경로도 변경 파일이 없으면 PR 생성을 건너뛰어요. + 같은 대상 레포에 열린 동기화 PR이 있으면 새 PR을 만들지 않아요. PR을 머지하거나 닫은 다음 실행에서 최신 원본으로 다시 동기화해요. 자동 동기화 PR은 작업 이슈가 없는 봇 작업이므로 일반 PR의 이슈 번호 규칙 대신 `chore: framework-agent-harness-sync` 제목을 사용해요. diff --git a/sync/apply.ts b/sync/apply.ts index feaa2a2..6fa5eaa 100644 --- a/sync/apply.ts +++ b/sync/apply.ts @@ -2,6 +2,7 @@ import { cp, lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { mergeManagedInstructions } from "./instructions.ts"; +import { mergeManagedAttributes } from "./attributes.ts"; import { syncItems, type SyncItem } from "./manifest.ts"; function containedPath(root, path) { @@ -25,14 +26,15 @@ export async function applyHarnessFiles({ sourceRoot, targetRoot, items = syncIt const sourceStat = await lstat(source); await mkdir(dirname(destination), { recursive: true }); - if (item.mode === "append-managed-instructions") { + if (item.mode === "append-managed-instructions" || item.mode === "merge-managed-attributes") { let existing = ""; try { existing = await readFile(destination, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } - await writeFile(destination, mergeManagedInstructions(existing, await readFile(source, "utf8"))); + const merge = item.mode === "merge-managed-attributes" ? mergeManagedAttributes : mergeManagedInstructions; + await writeFile(destination, merge(existing, await readFile(source, "utf8"))); copied.push(item.destination); continue; } diff --git a/sync/attributes.ts b/sync/attributes.ts new file mode 100644 index 0000000..5e18d42 --- /dev/null +++ b/sync/attributes.ts @@ -0,0 +1,46 @@ +import { mergeManagedInstructions } from "./instructions.ts"; + +export const MANAGED_ATTRIBUTES_MARKERS = { + start: "# framework-collaboration-harness:start", + end: "# framework-collaboration-harness:end" +}; + +// Keep the exact pre-marker rules even when the source rules change in the future. +const legacyHarnessLines = new Set([ + "- text=auto eol=lf", + "*.md text eol=lf linguist-detectable", + "*.yaml text eol=lf linguist-detectable", + "*.yml text eol=lf linguist-detectable", + "*.json text eol=lf linguist-detectable" +]); + +export function mergeManagedAttributes(existing = "", attributes: string) { + const markers = MANAGED_ATTRIBUTES_MARKERS; + const lines = existing.split(/(?<=\n)/); + let start = -1; + let offset = 0; + for (const line of lines) { + const text = line.replace(/\r?\n$/, ""); + if (text === markers.start) start = offset; + if (text === markers.end && start >= 0) { + const end = offset + line.length; + return existing.slice(0, start) + + mergeManagedInstructions(existing.slice(start, end), attributes, markers) + + existing.slice(end); + } + offset += line.length; + } + + const block = mergeManagedInstructions("", attributes, markers); + let inserted = false; + // Retain line endings and whitespace for every line not owned by the harness. + const migrated = lines.map((line) => { + if (!legacyHarnessLines.has(line.replace(/\r?\n$/, ""))) return line; + if (inserted) return ""; + inserted = true; + return block; + }).join(""); + + if (inserted) return migrated; + return `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${block}`; +} diff --git a/sync/github-app.ts b/sync/github-app.ts index 52d9b94..3e3a8d0 100644 --- a/sync/github-app.ts +++ b/sync/github-app.ts @@ -3,6 +3,7 @@ import { lstat, readdir, readFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { selectTargets } from "./discover.ts"; import { mergeManagedInstructions } from "./instructions.ts"; +import { mergeManagedAttributes } from "./attributes.ts"; import { syncPullRequestBody } from "./pr-body.ts"; import { syncBranch, syncCommitMessage, syncItems, syncPullRequestTitle, type SyncItem } from "./manifest.ts"; @@ -128,14 +129,15 @@ async function createSyncPullRequest({ client, target, files }) { const baseTree = await client.request(`/repos/${target.repository}/git/trees/${baseCommit.tree.sha}?recursive=1`); const existingBlobs = new Map(baseTree.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry.sha])); const resolvedFiles = await Promise.all(files.map(async (file) => { - if (file.mode !== "append-managed-instructions") return file; + if (file.mode !== "append-managed-instructions" && file.mode !== "merge-managed-attributes") return file; const existingSha = existingBlobs.get(file.path); let existing = ""; if (existingSha) { const blob = await client.request(`/repos/${target.repository}/git/blobs/${existingSha}`); existing = Buffer.from(blob.content, blob.encoding || "base64").toString("utf8"); } - const content = Buffer.from(mergeManagedInstructions(existing, file.content.toString("utf8"))); + const merge = file.mode === "merge-managed-attributes" ? mergeManagedAttributes : mergeManagedInstructions; + const content = Buffer.from(merge(existing, file.content.toString("utf8"))); return { ...file, content, sha: gitBlobSha(content) }; })); const changedFiles = resolvedFiles.filter((file) => existingBlobs.get(file.path) !== file.sha); diff --git a/sync/instructions.ts b/sync/instructions.ts index ef187cb..fb2afe8 100644 --- a/sync/instructions.ts +++ b/sync/instructions.ts @@ -1,13 +1,16 @@ export const MANAGED_INSTRUCTIONS_START = ""; export const MANAGED_INSTRUCTIONS_END = ""; -export function mergeManagedInstructions(existing = "", instructions) { - const block = `${MANAGED_INSTRUCTIONS_START}\n${instructions.trim()}\n${MANAGED_INSTRUCTIONS_END}\n`; - const start = existing.indexOf(MANAGED_INSTRUCTIONS_START); - const end = existing.indexOf(MANAGED_INSTRUCTIONS_END, start); +export function mergeManagedInstructions(existing = "", instructions, markers = { + start: MANAGED_INSTRUCTIONS_START, + end: MANAGED_INSTRUCTIONS_END +}) { + const block = `${markers.start}\n${instructions.trim()}\n${markers.end}\n`; + const start = existing.indexOf(markers.start); + const end = existing.indexOf(markers.end, start); if (start >= 0 && end >= start) { - return `${existing.slice(0, start)}${block}${existing.slice(end + MANAGED_INSTRUCTIONS_END.length).replace(/^\n?/, "")}`; + return `${existing.slice(0, start)}${block}${existing.slice(end + markers.end.length).replace(/^\r?\n/, "")}`; } return existing.length === 0 ? block : `${existing.replace(/\s*$/, "")}\n\n${block}`; diff --git a/sync/manifest.ts b/sync/manifest.ts index 69d6542..ecad1a7 100644 --- a/sync/manifest.ts +++ b/sync/manifest.ts @@ -28,7 +28,8 @@ export const syncItems: SyncItem[] = [ { id: "git-attributes", source: ".gitattributes", - destination: ".gitattributes" + destination: ".gitattributes", + mode: "merge-managed-attributes" }, ...issueTemplates.map((name) => ({ id: "issue-template-" + name, diff --git a/tests/attributes.test.ts b/tests/attributes.test.ts new file mode 100644 index 0000000..8da98a7 --- /dev/null +++ b/tests/attributes.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { applyHarnessFiles } from "../sync/apply.ts"; +import { mergeManagedAttributes } from "../sync/attributes.ts"; +import { syncItems } from "../sync/manifest.ts"; + +const source = await readFile(new URL("../.gitattributes", import.meta.url), "utf8"); +const block = `# framework-collaboration-harness:start\n${source.trim()}\n# framework-collaboration-harness:end\n`; +const clientLfs = "apps/android/InnoLive/app/src/main/assets/privacy-face.onnx filter=lfs diff=lfs merge=lfs -text\n" + + "apps/android/InnoLive/app/src/main/assets/privacy-face.tflite filter=lfs diff=lfs merge=lfs -text\n"; +const aiLfs = "*.pt filter=lfs diff=lfs merge=lfs -text\n"; +const staleBlock = "# framework-collaboration-harness:start\n*.old text\n# framework-collaboration-harness:end\n"; + +const cases = [ + { name: "파일이 없는 새 저장소", existing: undefined, expected: block }, + { name: "빈 파일", existing: "", expected: block }, + { name: "LFS 규칙만 있는 파일", existing: aiLfs, expected: aiLfs + block }, + { name: "마지막 개행이 없는 파일", existing: aiLfs.trimEnd(), expected: aiLfs + block }, + { name: "마커 없는 기존 하네스 파일", existing: source, expected: block + "\n" }, + { name: "innolive-client의 기존 규칙과 두 LFS 규칙", existing: source + clientLfs, expected: block + "\n" + clientLfs }, + { name: "innolive-ai의 기존 규칙과 LFS 규칙", existing: source + aiLfs, expected: block + "\n" + aiLfs }, + { + name: "주석에서 마커를 언급하는 고유 내용", + existing: "# example: # framework-collaboration-harness:start\n" + aiLfs + "# example: # framework-collaboration-harness:end\n", + expected: "# example: # framework-collaboration-harness:start\n" + aiLfs + "# example: # framework-collaboration-harness:end\n" + block + }, + { + name: "닫는 마커가 없는 기존 내용", + existing: "# framework-collaboration-harness:start\n" + aiLfs, + expected: "# framework-collaboration-harness:start\n" + aiLfs + block + }, + { + name: "기존 규칙 사이의 주석·공백·CRLF·유사 규칙", + existing: "# custom \r\n- text=auto eol=lf\r\n" + aiLfs + " \r\n*.md text eol=lf linguist-detectable\r\n*.json text eol=crlf\n# tail ", + expected: "# custom \r\n" + block + aiLfs + " \r\n*.json text eol=crlf\n# tail " + }, + { + name: "관리 블록 양쪽의 고유 내용과 동일한 규칙 보존", + existing: "# before\n- text=auto eol=lf\n\n" + staleBlock + clientLfs + "\n# tail ", + expected: "# before\n- text=auto eol=lf\n\n" + block + clientLfs + "\n# tail " + }, + { + name: "CRLF 관리 블록 뒤 빈 줄 보존", + existing: staleBlock.replaceAll("\n", "\r\n") + "\r\n" + aiLfs, + expected: block + "\r\n" + aiLfs + } +]; + +for (const { name, existing, expected } of cases) { + test(`.gitattributes 로컬 적용 및 재실행: ${name}`, async (t) => { + const targetRoot = await mkdtemp(join(tmpdir(), "harness-attributes-")); + t.after(() => rm(targetRoot, { recursive: true, force: true })); + if (existing !== undefined) await writeFile(join(targetRoot, ".gitattributes"), existing); + const options = { + sourceRoot: process.cwd(), + targetRoot, + items: syncItems.filter((item) => item.destination === ".gitattributes") + }; + + await applyHarnessFiles(options); + assert.equal(await readFile(join(targetRoot, ".gitattributes"), "utf8"), expected); + await applyHarnessFiles(options); + assert.equal(await readFile(join(targetRoot, ".gitattributes"), "utf8"), expected); + }); +} + +test("원본 규칙이 바뀌어도 이전 하네스 규칙을 중복 없이 이전해요", () => { + const updatedSource = "*.md text eol=lf\n"; + const expected = "# framework-collaboration-harness:start\n*.md text eol=lf\n# framework-collaboration-harness:end\n\n" + aiLfs; + const merged = mergeManagedAttributes(source + aiLfs, updatedSource); + assert.equal(merged, expected); + assert.equal(mergeManagedAttributes(merged, updatedSource), expected); +}); diff --git a/tests/harness-sync-installation.test.ts b/tests/harness-sync-installation.test.ts index b8b4c98..3f41618 100644 --- a/tests/harness-sync-installation.test.ts +++ b/tests/harness-sync-installation.test.ts @@ -1,13 +1,72 @@ import assert from "node:assert/strict"; -import { generateKeyPairSync } from "node:crypto"; +import { createHash, generateKeyPairSync } from "node:crypto"; import test from "node:test"; -import { createAppJwt, syncHarnessInstallation } from "../sync/github-app.ts"; +import { createAppJwt, loadSyncTree, syncHarnessInstallation } from "../sync/github-app.ts"; +import { mergeManagedInstructions } from "../sync/instructions.ts"; import { syncPullRequestBody } from "../sync/pr-body.ts"; function json(body, status = 200) { return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body }; } +for (const scenario of ["missing", "legacy", "managed"]) { + test(`GitHub App .gitattributes 보존 및 변경 없는 재실행의 PR 미생성: ${scenario}`, async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const files = await loadSyncTree({ sourceRoot: process.cwd() }); + const source = files.find((file) => file.path === ".gitattributes").content.toString("utf8"); + const block = `# framework-collaboration-harness:start\n${source.trim()}\n# framework-collaboration-harness:end\n`; + const lfs = "*.pt filter=lfs diff=lfs merge=lfs -text\n"; + const remote = new Map(files.filter((file) => file.path !== ".gitattributes").map((file) => [ + file.path, + file.mode === "append-managed-instructions" ? mergeManagedInstructions("", file.content.toString("utf8")) : file.content.toString("utf8") + ])); + if (scenario === "legacy") remote.set(".gitattributes", source + lfs); + if (scenario === "managed") remote.set(".gitattributes", lfs + "# framework-collaboration-harness:start\n*.old text\n# framework-collaboration-harness:end\n# custom\n"); + const expected = scenario === "missing" ? block : scenario === "legacy" ? block + "\n" + lfs : lfs + block + "# custom\n"; + const writes = []; + const blobSha = (content: string) => createHash("sha1").update(`blob ${Buffer.byteLength(content)}\0`).update(content).digest("hex"); + const options = { + installationId: 10, + config: { appId: "Iv1.test", privateKey: privateKey.export({ type: "pkcs1", format: "pem" }), sourceRepository: "team-framework/framework-agent-harness-sync" }, + sourceRoot: process.cwd(), + fetchImpl: async (url, request: any = {}) => { + const path = new URL(url).pathname + new URL(url).search; + if (path === "/app/installations/10/access_tokens") return json({ token: "installation-token" }, 201); + if (path === "/installation/repositories?per_page=100&page=1") return json({ repositories: [ + { full_name: "team-framework/target", name: "target", default_branch: "main" } + ] }); + if (request.method === "POST") writes.push(path); + if (path.startsWith("/repos/team-framework/target/pulls?")) return json([]); + if (path.endsWith("/git/ref/heads/main")) return json({ object: { sha: "base-commit" } }); + if (path.endsWith("/git/commits/base-commit")) return json({ sha: "base-commit", tree: { sha: "base-tree" } }); + if (path.endsWith("/git/trees/base-tree?recursive=1")) return json({ tree: [...remote].map(([path, content]) => ({ path, type: "blob", sha: blobSha(content) })) }); + if (path.includes("/git/blobs/")) { + const content = [...remote.values()].find((content) => path.endsWith(`/git/blobs/${blobSha(content)}`)); + assert.notEqual(content, undefined); + return json({ encoding: "base64", content: Buffer.from(content).toString("base64") }); + } + if (path.endsWith("/git/trees") && request.method === "POST") { + const { tree } = JSON.parse(request.body); + assert.deepEqual(tree, [{ path: ".gitattributes", mode: "100644", type: "blob", content: expected }]); + // Model the resulting file already applied to main on the next sync. + for (const file of tree) remote.set(file.path, file.content); + return json({ sha: "sync-tree" }, 201); + } + if (path.endsWith("/git/commits") && request.method === "POST") return json({ sha: "sync-commit" }, 201); + if (path.endsWith("/git/refs") && request.method === "POST") return json({}, 201); + if (path.endsWith("/pulls") && request.method === "POST") return json({ html_url: "https://github.com/team-framework/target/pull/1" }, 201); + throw new Error(`예상하지 않은 GitHub 요청: ${request.method || "GET"} ${path}`); + } + }; + + assert.equal((await syncHarnessInstallation(options))[0].status, "pr_created"); + assert.equal(writes.length, 4); + writes.length = 0; + assert.deepEqual(await syncHarnessInstallation(options), [{ repository: "team-framework/target", status: "no_changes" }]); + assert.deepEqual(writes, []); + }); +} + test("설치된 위키와 MCP 저장소는 읽거나 쓰지 않고 다른 레포에 전체 스킬 Draft PR을 만들어요", async () => { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const calls = [];