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
4 changes: 2 additions & 2 deletions .github/workflows/cloclo.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions .github/workflows/cloclo.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ safe-outputs:
expires: 2d
title-prefix: "[cloclo] "
labels: [automation, cloclo]
excluded-files:
- ".github/workflows/*.lock.yml"
add-comment:
max: 1
messages:
Expand Down
168 changes: 168 additions & 0 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -590,3 +590,171 @@ ${diffs}
expect(result.error).toContain("protected files");
});
});

// excluded-files exclusion list
// ──────────────────────────────────────────────────────

describe("create_pull_request - excluded-files exclusion list", () => {
let tempDir;
let originalEnv;

beforeEach(() => {
originalEnv = { ...process.env };
process.env.GH_AW_WORKFLOW_ID = "test-workflow";
process.env.GITHUB_REPOSITORY = "test-owner/test-repo";
process.env.GITHUB_BASE_REF = "main";
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-ignored-test-"));

global.core = {
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
startGroup: vi.fn(),
endGroup: vi.fn(),
summary: {
addRaw: vi.fn().mockReturnThis(),
write: vi.fn().mockResolvedValue(undefined),
},
};
global.github = {
rest: {
pulls: {
create: vi.fn().mockResolvedValue({ data: { number: 1, html_url: "https://github.com/test" } }),
},
repos: {
get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }),
},
},
graphql: vi.fn(),
};
global.context = {
eventName: "workflow_dispatch",
repo: { owner: "test-owner", repo: "test-repo" },
payload: {},
};
global.exec = {
exec: vi.fn().mockResolvedValue(0),
getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }),
};

// Clear module cache so globals are picked up fresh
delete require.cache[require.resolve("./create_pull_request.cjs")];
});

afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);

if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}

delete global.core;
delete global.github;
delete global.context;
delete global.exec;
vi.clearAllMocks();
});

/**
* Creates a minimal git patch touching the given file paths.
*/
function createPatchWithFiles(...filePaths) {
const diffs = filePaths
.map(
p => `diff --git a/${p} b/${p}
new file mode 100644
index 0000000..abc1234
--- /dev/null
+++ b/${p}
@@ -0,0 +1 @@
+content
`
)
.join("\n");
return `From abc123 Mon Sep 17 00:00:00 2001
From: Test Author <test@example.com>
Date: Mon, 1 Jan 2024 00:00:00 +0000
Subject: [PATCH] Test commit

${diffs}
--
2.34.1
`;
}

function writePatch(content) {
const p = path.join(tempDir, "test.patch");
fs.writeFileSync(p, content);
return p;
}

it("should ignore files matching excluded-files patterns (not blocked by allowed-files)", async () => {
// excluded-files are excluded at patch generation time via git :(exclude) pathspecs.
// Simulate post-generation: the patch already contains only the non-ignored file.
const patchPath = writePatch(createPatchWithFiles("src/index.js"));

const { main } = require("./create_pull_request.cjs");
const handler = await main({
excluded_files: ["auto-generated/**"],
allowed_files: ["src/**"],
});
const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {});

expect(result.error || "").not.toContain("outside the allowed-files list");
});

it("should still block non-ignored files that violate the allowed-files list", async () => {
const patchPath = writePatch(createPatchWithFiles("src/index.js", "other/file.txt"));

const { main } = require("./create_pull_request.cjs");
const handler = await main({
excluded_files: ["auto-generated/**"],
allowed_files: ["src/**"],
});
const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("outside the allowed-files list");
expect(result.error).toContain("other/file.txt");
expect(result.error).not.toContain("src/index.js");
});

it("should ignore files matching excluded-files patterns (not blocked by protected-files)", async () => {
// excluded-files are excluded at patch generation time via git :(exclude) pathspecs.
// Simulate post-generation: the patch already contains only the non-ignored file.
const patchPath = writePatch(createPatchWithFiles("src/index.js"));

const { main } = require("./create_pull_request.cjs");
const handler = await main({
excluded_files: ["package.json"],
protected_files: ["package.json"],
protected_files_policy: "blocked",
});
const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {});

expect(result.error || "").not.toContain("protected files");
});

it("should allow when all patch files are ignored (even with allowed-files set)", async () => {
// excluded-files are excluded at patch generation time via git :(exclude) pathspecs.
// Simulate post-generation: all files were excluded so the patch file is absent.
const { main } = require("./create_pull_request.cjs");
const handler = await main({
excluded_files: ["dist/**"],
allowed_files: ["src/**"],
});
// No patch file — simulates all changes being ignored at generation time
const result = await handler({ patch_path: path.join(tempDir, "nonexistent.patch"), title: "Test PR", body: "" }, {});

// No patch → treated as no changes, not an allowlist violation
expect(result.error || "").not.toContain("outside the allowed-files list");
});
});
28 changes: 23 additions & 5 deletions actions/setup/js/generate_git_patch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,31 @@ function getPatchPathForRepo(branchName, repoSlug) {
* Required for multi-repo scenarios to prevent patch file collisions.
* @param {string} [options.token] - GitHub token for git authentication. Falls back to GITHUB_TOKEN env var.
* Use this for cross-repo scenarios where a custom PAT with access to the target repo is needed.
* @param {string[]} [options.excludedFiles] - Glob patterns for files to exclude from the patch.
* Each pattern is passed to `git format-patch` as a `:(exclude)<pattern>` magic pathspec so
* matching files are never included in the generated patch.
* @returns {Promise<Object>} Object with patch info or error
*/
async function generateGitPatch(branchName, baseBranch, options = {}) {
const mode = options.mode || "full";
// Support custom cwd for multi-repo scenarios
const cwd = options.cwd || process.env.GITHUB_WORKSPACE || process.cwd();
// Include repo slug in patch path for multi-repo disambiguation

// Build :(exclude) pathspec arguments from the excludedFiles option.
// These are appended after "--" so git treats them as pathspecs, not revisions.
// Using git's native pathspec magic keeps the exclusions out of the patch entirely
// without any post-processing of the generated patch file.
const excludePathspecs = Array.isArray(options.excludedFiles) && options.excludedFiles.length > 0 ? options.excludedFiles.map(p => `:(exclude)${p}`) : [];

/**
* Returns the arguments to append to a format-patch call when excludedFiles is set.
* Produces ["--", ":(exclude)pattern1", ":(exclude)pattern2", ...] or [].
* @returns {string[]}
*/
function excludeArgs() {
return excludePathspecs.length > 0 ? ["--", ...excludePathspecs] : [];
}
const patchPath = options.repoSlug ? getPatchPathForRepo(branchName, options.repoSlug) : getPatchPath(branchName);

// Validate baseBranch early to avoid confusing git errors (e.g., origin/undefined)
Expand Down Expand Up @@ -235,7 +253,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {

if (commitCount > 0) {
// Generate patch from the determined base to the branch
const patchContent = execGitSync(["format-patch", `${baseRef}..${branchName}`, "--stdout"], { cwd });
const patchContent = execGitSync(["format-patch", `${baseRef}..${branchName}`, "--stdout", ...excludeArgs()], { cwd });

if (patchContent && patchContent.trim()) {
fs.writeFileSync(patchPath, patchContent, "utf8");
Expand Down Expand Up @@ -304,7 +322,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {

if (commitCount > 0) {
// Generate patch from GITHUB_SHA to HEAD
const patchContent = execGitSync(["format-patch", `${githubSha}..HEAD`, "--stdout"], { cwd });
const patchContent = execGitSync(["format-patch", `${githubSha}..HEAD`, "--stdout", ...excludeArgs()], { cwd });

if (patchContent && patchContent.trim()) {
fs.writeFileSync(patchPath, patchContent, "utf8");
Expand Down Expand Up @@ -339,8 +357,8 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
if (remoteRefs.length > 0) {
// Find commits on current branch not reachable from any remote ref
// This gets commits the agent added that haven't been pushed anywhere
const excludeArgs = remoteRefs.flatMap(ref => ["--not", ref]);
const revListArgs = ["rev-list", "--count", branchName, ...excludeArgs];
const remoteExcludeArgs = remoteRefs.flatMap(ref => ["--not", ref]);
const revListArgs = ["rev-list", "--count", branchName, ...remoteExcludeArgs];

const commitCount = parseInt(execGitSync(revListArgs, { cwd }).trim(), 10);
debugLog(`Strategy 3: Found ${commitCount} commits not reachable from any remote ref`);
Expand All @@ -362,7 +380,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
}

if (baseCommit) {
const patchContent = execGitSync(["format-patch", `${baseCommit}..${branchName}`, "--stdout"], { cwd });
const patchContent = execGitSync(["format-patch", `${baseCommit}..${branchName}`, "--stdout", ...excludeArgs()], { cwd });

if (patchContent && patchContent.trim()) {
fs.writeFileSync(patchPath, patchContent, "utf8");
Expand Down
109 changes: 109 additions & 0 deletions actions/setup/js/generate_git_patch.test.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import { createRequire } from "module";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";

const require = createRequire(import.meta.url);

describe("generateGitPatch", () => {
let originalEnv;
Expand Down Expand Up @@ -376,3 +383,105 @@ describe("getPatchPath", () => {
expect(getPatchPath("Feature/BRANCH")).toBe("/tmp/gh-aw/aw-feature-branch.patch");
});
});

// ──────────────────────────────────────────────────────
// excludedFiles option – end-to-end with a real git repo
// ──────────────────────────────────────────────────────

describe("generateGitPatch – excludedFiles option", () => {
let repoDir;
let originalEnv;

beforeEach(() => {
originalEnv = { GITHUB_WORKSPACE: process.env.GITHUB_WORKSPACE, GITHUB_SHA: process.env.GITHUB_SHA };

// Set up the core global required by git_helpers.cjs
global.core = { debug: () => {}, info: () => {}, warning: () => {}, error: () => {} };

// Create an isolated git repo in a temp directory
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-patch-test-"));
execSync("git init -b main", { cwd: repoDir });
execSync('git config user.email "test@example.com"', { cwd: repoDir });
execSync('git config user.name "Test"', { cwd: repoDir });

// Initial commit so the repo has a base
fs.writeFileSync(path.join(repoDir, "README.md"), "# Repo\n");
execSync("git add .", { cwd: repoDir });
execSync('git commit -m "init"', { cwd: repoDir });

// Record the initial commit SHA for GITHUB_SHA (Strategy 2 base)
const sha = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim();
process.env.GITHUB_SHA = sha;
// Clear GITHUB_WORKSPACE so the cwd option is used instead
delete process.env.GITHUB_WORKSPACE;

// Reset module cache so each test gets a fresh module instance
delete require.cache[require.resolve("./generate_git_patch.cjs")];
});

afterEach(() => {
// Restore env
Object.entries(originalEnv).forEach(([k, v]) => {
if (v !== undefined) process.env[k] = v;
else delete process.env[k];
});
// Clean up temp repo
if (repoDir && fs.existsSync(repoDir)) {
fs.rmSync(repoDir, { recursive: true, force: true });
}
delete require.cache[require.resolve("./generate_git_patch.cjs")];
delete global.core;
});

function commitFiles(files) {
for (const [filePath, content] of Object.entries(files)) {
const abs = path.join(repoDir, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
execSync("git add .", { cwd: repoDir });
execSync('git commit -m "add files"', { cwd: repoDir });
}

it("should include all files when excludedFiles is not set", async () => {
commitFiles({
"src/index.js": "console.log('hello');\n",
"dist/bundle.js": "/* bundled */\n",
});

const { generateGitPatch } = require("./generate_git_patch.cjs");
const result = await generateGitPatch(null, "main", { cwd: repoDir });

expect(result.success).toBe(true);
const patch = fs.readFileSync(result.patchPath, "utf8");
expect(patch).toContain("src/index.js");
expect(patch).toContain("dist/bundle.js");
});

it("should exclude files matching excludedFiles patterns from the patch", async () => {
commitFiles({
"src/index.js": "console.log('hello');\n",
"dist/bundle.js": "/* bundled */\n",
});

const { generateGitPatch } = require("./generate_git_patch.cjs");
const result = await generateGitPatch(null, "main", { cwd: repoDir, excludedFiles: ["dist/**"] });

expect(result.success).toBe(true);
const patch = fs.readFileSync(result.patchPath, "utf8");
expect(patch).toContain("src/index.js");
expect(patch).not.toContain("dist/bundle.js");
});

it("should return no patch when all files are ignored", async () => {
commitFiles({
"dist/bundle.js": "/* bundled */\n",
});

const { generateGitPatch } = require("./generate_git_patch.cjs");
const result = await generateGitPatch(null, "main", { cwd: repoDir, excludedFiles: ["dist/**"] });

// All changes were excluded — patch is empty so generation reports no changes
expect(result.success).toBe(false);
});
});
Loading
Loading