diff --git a/.github/workflows/sync-harness.yml b/.github/workflows/sync-harness.yml index 5966e68..3cd9876 100644 --- a/.github/workflows/sync-harness.yml +++ b/.github/workflows/sync-harness.yml @@ -109,6 +109,7 @@ jobs: token: ${{ steps.app-token.outputs.token }} path: target persist-credentials: false + fetch-depth: 0 - name: 협업 하네스 동기화 env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -136,8 +137,15 @@ jobs: git -C target config user.name "$APP_SLUG[bot]" git -C target config user.email "$bot_id+$APP_SLUG[bot]@users.noreply.github.com" git -C target remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPOSITORY}.git" - if git -C target ls-remote --exit-code --heads origin "$branch" >/dev/null; then - git -C target push origin --delete "$branch" + if bash source/sync/check-branch.sh target "$branch"; then + : + else + branch_status=$? + if [ "$branch_status" -ne 3 ]; then + exit "$branch_status" + fi + npx --prefix source tsx source/sync/write-result.ts --file "$result_file" --repository "$TARGET_REPOSITORY" --status skipped --detail "잔존 $branch 브랜치에 기본 브랜치에 없는 커밋이 있어 보존했어요. 관리자 확인이 필요해요." + exit 0 fi git -C target switch -c "$branch" sync_paths=(.agent .codex .claude .github docs/collaboration.md .gitattributes AGENTS.md CLAUDE.md) @@ -185,7 +193,7 @@ jobs: with: pattern: sync-result-* path: results - merge-multiple: true + merge-multiple: false - name: 동기화 결과 요약 run: | mkdir -p results diff --git a/sync/attributes.ts b/sync/attributes.ts index 5e18d42..eafe2d6 100644 --- a/sync/attributes.ts +++ b/sync/attributes.ts @@ -33,9 +33,14 @@ export function mergeManagedAttributes(existing = "", attributes: string) { const block = mergeManagedInstructions("", attributes, markers); let inserted = false; + const isLegacy = (line: string) => legacyHarnessLines.has(line.replace(/\r?\n$/, "")); // 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; + const migrated = lines.map((line, index) => { + // A separator between removed rules must not become a new blank line at EOF. + const tail = lines.slice(index + 1); + if (inserted && /^\s*$/.test(line) && tail.some(isLegacy) + && tail.every((remaining) => isLegacy(remaining) || /^\s*$/.test(remaining))) return ""; + if (!isLegacy(line)) return line; if (inserted) return ""; inserted = true; return block; diff --git a/sync/check-branch.sh b/sync/check-branch.sh new file mode 100644 index 0000000..48a581b --- /dev/null +++ b/sync/check-branch.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +target=$1 +branch=$2 + +# An absent branch is safe; transport/authentication errors must still fail. +remote_ref=$(git -C "$target" ls-remote --heads origin "refs/heads/$branch") +if [ -z "$remote_ref" ]; then + exit 0 +fi + +git -C "$target" fetch --no-tags origin "refs/heads/$branch" +if git -C "$target" merge-base --is-ancestor FETCH_HEAD HEAD; then + # The old sync was merged: a normal push from the base will fast-forward it. + exit 0 +else + status=$? + if [ "$status" -eq 1 ]; then + # Preserve unmerged commits. The caller reports an actionable skipped result. + exit 3 + fi + exit "$status" +fi diff --git a/sync/report.ts b/sync/report.ts index 84bec98..e724ccb 100644 --- a/sync/report.ts +++ b/sync/report.ts @@ -1,4 +1,5 @@ import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; const statusText = { @@ -26,6 +27,29 @@ export function renderSyncReport(results) { ].join("\n"); } +export function parseSyncResults(content: string) { + try { + return [JSON.parse(content)]; + } catch { + // Accept older newline-delimited results without hiding damaged JSON. + return content.split(/\r?\n/).filter(line => line.trim()).map(line => JSON.parse(line)); + } +} + +export async function readSyncResults(directory: string): Promise { + const files = await readdir(directory, { withFileTypes: true }); + const results = await Promise.all(files.map(async (file) => { + const path = join(directory, file.name); + // Keep artifact directories separate: every target uses sync-result.json. + if (file.isDirectory()) return readSyncResults(path); + if (file.isFile() && file.name.endsWith(".json")) { + return parseSyncResults(await readFile(path, "utf8")); + } + return []; + })); + return results.flat(); +} + function argument(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : null; @@ -35,10 +59,5 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { const resultsDirectory = argument("--results-directory"); if (!resultsDirectory) throw new Error("사용법: tsx sync/report.ts --results-directory "); - const files = await readdir(resultsDirectory, { withFileTypes: true }); - const results = await Promise.all(files - .filter((file) => file.isFile() && file.name.endsWith(".json")) - .map(async (file) => JSON.parse(await readFile(resultsDirectory + "/" + file.name, "utf8")))); - - console.log(renderSyncReport(results)); + console.log(renderSyncReport(await readSyncResults(resultsDirectory))); } diff --git a/tests/attributes.test.ts b/tests/attributes.test.ts index 8da98a7..6330b31 100644 --- a/tests/attributes.test.ts +++ b/tests/attributes.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,7 +20,9 @@ const cases = [ { 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: "마커 없는 기존 하네스 파일", existing: source, expected: block }, + { name: "고유 규칙 뒤의 기존 하네스 파일", existing: aiLfs + source, expected: aiLfs + block }, + { name: "CRLF 기존 하네스 파일", existing: source.replaceAll("\n", "\r\n"), expected: block }, { name: "innolive-client의 기존 규칙과 두 LFS 규칙", existing: source + clientLfs, expected: block + "\n" + clientLfs }, { name: "innolive-ai의 기존 규칙과 LFS 규칙", existing: source + aiLfs, expected: block + "\n" + aiLfs }, { @@ -67,6 +70,26 @@ for (const { name, existing, expected } of cases) { }); } +test("innolive-server의 기존 속성 변환은 Git 공백 검사 통과 및 커밋 후 멱등성 유지", async (t) => { + const targetRoot = await mkdtemp(join(tmpdir(), "harness-attributes-git-")); + t.after(() => rm(targetRoot, { recursive: true, force: true })); + const git = (...args: string[]) => execFileSync("git", ["-C", targetRoot, ...args], { encoding: "utf8" }); + git("init", "-b", "main"); + git("config", "user.name", "test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(targetRoot, ".gitattributes"), source); + git("add", ".gitattributes"); + git("commit", "-m", "initial"); + const options = { sourceRoot: process.cwd(), targetRoot, items: syncItems.filter(item => item.destination === ".gitattributes") }; + await applyHarnessFiles(options); + git("diff", "--check"); + git("add", ".gitattributes"); + git("diff", "--cached", "--check"); + git("commit", "-m", "sync"); + await applyHarnessFiles(options); + assert.equal(git("status", "--porcelain"), ""); +}); + 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; diff --git a/tests/sync-branch.test.ts b/tests/sync-branch.test.ts new file mode 100644 index 0000000..71e049d --- /dev/null +++ b/tests/sync-branch.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const branch = "harness-sync/framework-agent"; +const script = resolve("sync/check-branch.sh"); + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "harness-branch-")); + t.after(() => rm(root, { recursive: true, force: true })); + const remote = join(root, "remote.git"); + const target = join(root, "target"); + const git = (...args: string[]) => execFileSync("git", ["-C", target, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + execFileSync("git", ["init", "--bare", "-b", "main", remote]); + execFileSync("git", ["clone", remote, target], { stdio: "pipe" }); + git("config", "user.name", "test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(target, "product.txt"), "product\n"); + git("add", "."); + git("commit", "-m", "initial"); + git("push", "origin", "main"); + const check = () => spawnSync("bash", [script, target, branch], { encoding: "utf8" }); + return { root, remote, target, git, check }; +} + +test("동기화 브랜치가 없으면 생성하고 일반 push 가능", async (t) => { + const { git, check } = await fixture(t); + assert.equal(check().status, 0); + git("switch", "-c", branch); + git("commit", "--allow-empty", "-m", "sync"); + git("push", "origin", `HEAD:${branch}`); + assert.equal(git("rev-parse", `origin/${branch}`), git("rev-parse", "HEAD")); +}); + +test("main에 포함된 잔존 브랜치는 삭제 없이 일반 push 및 재실행 가능", async (t) => { + const { target, git, check } = await fixture(t); + const oldTip = git("rev-parse", "HEAD"); + git("push", "origin", `HEAD:${branch}`); + git("commit", "--allow-empty", "-m", "base advanced"); + git("push", "origin", "main"); + // Match the workflow: apply files before checking remote branch ancestry. + await writeFile(join(target, ".gitattributes"), "*.md text eol=lf\n"); + assert.equal(check().status, 0); + assert.equal(git("rev-parse", "FETCH_HEAD"), oldTip); + assert.equal(await readFile(join(target, ".gitattributes"), "utf8"), "*.md text eol=lf\n"); + git("switch", "-c", branch); + git("add", ".gitattributes"); + git("commit", "-m", "sync"); + git("push", "origin", `HEAD:${branch}`); + assert.equal(check().status, 0); + assert.equal(git("status", "--porcelain"), ""); + git("merge-base", "--is-ancestor", oldTip, "HEAD"); +}); + +test("기본 브랜치에 없는 잔존 커밋은 건너뜀 상태로 반환하고 보존", async (t) => { + const { git, check } = await fixture(t); + git("switch", "-c", branch); + git("commit", "--allow-empty", "-m", "unmerged sync"); + const remoteTip = git("rev-parse", "HEAD"); + git("push", "origin", `HEAD:${branch}`); + git("switch", "main"); + git("commit", "--allow-empty", "-m", "base advanced"); + const baseTip = git("rev-parse", "HEAD"); + assert.equal(check().status, 3); + assert.equal(check().status, 3); + assert.equal(git("rev-parse", "HEAD"), baseTip); + assert.equal(git("ls-remote", "--heads", "origin", `refs/heads/${branch}`).split(/\s/)[0], remoteTip); +}); + +test("원격 조회 실패는 브랜치 없음이나 안전한 건너뜀으로 숨기지 않음", async (t) => { + const { root, git, check } = await fixture(t); + git("remote", "set-url", "origin", join(root, "missing.git")); + const result = check(); + assert.notEqual(result.status, 0); + assert.notEqual(result.status, 3); +}); + +test("워크플로는 전체 이력으로 잔존 브랜치를 확인하고 삭제·강제 push 없이 결과 기록", async () => { + const workflow = await readFile(".github/workflows/sync-harness.yml", "utf8"); + assert.match(workflow, /path: target\n\s+persist-credentials: false\n\s+fetch-depth: 0/); + assert.match(workflow, /bash source\/sync\/check-branch\.sh target "\$branch"/); + assert.match(workflow, /--status skipped --detail/); + assert.doesNotMatch(workflow, /git[^\n]*push[^\n]*(--delete|--force|\s-f\s)/); +}); diff --git a/tests/sync-report.test.ts b/tests/sync-report.test.ts new file mode 100644 index 0000000..a19c32c --- /dev/null +++ b/tests/sync-report.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { parseSyncResults, readSyncResults, renderSyncReport } from "../sync/report.ts"; + +const client = { repository: "team-framework/innolive-client", status: "pr_created", detail: "https://github.com/team-framework/innolive-client/pull/793" }; +const ai = { repository: "team-framework/innolive-ai", status: "pr_created", detail: "https://github.com/team-framework/innolive-ai/pull/81" }; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "harness-report-")); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +test("동일 파일명의 아티팩트를 분리된 디렉터리에서 모두 보고", async (t) => { + const root = await fixture(t); + for (const result of [client, ai]) { + const directory = join(root, `sync-result-${result.repository.split("/")[1]}`); + await mkdir(directory); + await writeFile(join(directory, "sync-result.json"), JSON.stringify(result) + "\n"); + } + const results = await readSyncResults(root); + assert.equal(results.length, 2); + const report = execFileSync(process.execPath, ["--import", "tsx", "sync/report.ts", "--results-directory", root], { encoding: "utf8" }); + assert.match(report, /innolive-client.*793/); + assert.match(report, /innolive-ai.*81/); + const workflow = await readFile(".github/workflows/sync-harness.yml", "utf8"); + assert.match(workflow, /merge-multiple: false/); +}); + +test("단일 JSON과 여러 JSON 줄·빈 줄·CRLF를 지원", () => { + assert.deepEqual(parseSyncResults(JSON.stringify(client, null, 2)), [client]); + assert.deepEqual(parseSyncResults(["", JSON.stringify(client), "", JSON.stringify(ai), ""].join("\r\n")), [client, ai]); +}); + +test("기존 평면 결과와 중첩 JSONL 결과를 함께 읽고 다른 파일은 무시", async (t) => { + const root = await fixture(t); + await writeFile(join(root, "sync-result.json"), JSON.stringify(client)); + await mkdir(join(root, "nested")); + await writeFile(join(root, "nested", "sync-result.json"), JSON.stringify(ai) + "\n" + JSON.stringify({ repository: "server", status: "skipped", detail: "관리자 확인 필요" }) + "\n"); + await writeFile(join(root, "README.txt"), "not JSON"); + const results = await readSyncResults(root); + assert.equal(results.length, 3); + assert.match(renderSyncReport(results), /server.*건너뜀.*관리자 확인 필요/); +}); + +test("손상된 JSON 꼬리는 조용히 버리지 않음", () => { + assert.throws(() => parseSyncResults(JSON.stringify(ai) + '\nll/793"}\n'), SyntaxError); +}); + +test("write-result 재실행은 append 없이 긴 이전 결과를 완전히 덮어씀", async (t) => { + const root = await fixture(t); + const file = join(root, "nested", "sync-result.json"); + for (const result of [client, ai]) { + execFileSync(process.execPath, ["--import", "tsx", "sync/write-result.ts", "--file", file, "--repository", result.repository, "--status", result.status, "--detail", result.detail]); + } + assert.equal(await readFile(file, "utf8"), JSON.stringify(ai) + "\n"); +}); + +test("결과가 없는 디렉터리는 기존 빈 보고서 유지", async (t) => { + const results = await readSyncResults(await fixture(t)); + assert.deepEqual(results, []); + assert.match(renderSyncReport(results), /동기화 대상 레포가 없어요/); +});