Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/sync-harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -185,7 +193,7 @@ jobs:
with:
pattern: sync-result-*
path: results
merge-multiple: true
merge-multiple: false
- name: 동기화 결과 요약
run: |
mkdir -p results
Expand Down
9 changes: 7 additions & 2 deletions sync/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions sync/check-branch.sh
Original file line number Diff line number Diff line change
@@ -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
31 changes: 25 additions & 6 deletions sync/report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

const statusText = {
Expand Down Expand Up @@ -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<any[]> {
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;
Expand All @@ -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 <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)));
}
25 changes: 24 additions & 1 deletion tests/attributes.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 },
{
Expand Down Expand Up @@ -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;
Expand Down
87 changes: 87 additions & 0 deletions tests/sync-branch.test.ts
Original file line number Diff line number Diff line change
@@ -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)/);
});
67 changes: 67 additions & 0 deletions tests/sync-report.test.ts
Original file line number Diff line number Diff line change
@@ -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), /동기화 대상 레포가 없어요/);
});
Loading