Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
66ccc2e
fix: prevent merge conflicts and improve parallel agent isolation
BasselBlal Jan 28, 2026
1ef4526
Update cli/src/execution/sandbox.ts
BasselBlal Jan 28, 2026
d2371db
Update cli/src/execution/sandbox.ts
BasselBlal Jan 28, 2026
516c7a0
Update cli/src/execution/sandbox.ts
BasselBlal Jan 28, 2026
6da97ea
fix
BasselBlal Jan 28, 2026
8f77556
fix
BasselBlal Jan 28, 2026
706af00
fix
BasselBlal Jan 28, 2026
da390be
fix
BasselBlal Jan 28, 2026
58cef1e
fix
BasselBlal Jan 28, 2026
92137f7
Apply suggestion from @greptile-apps[bot]
BasselBlal Jan 28, 2026
621f74e
Apply suggestions from code review
BasselBlal Jan 28, 2026
29f58d8
fix
BasselBlal Jan 28, 2026
e626465
fix
BasselBlal Jan 28, 2026
7a26a1f
fix
BasselBlal Jan 28, 2026
b19020f
fix
BasselBlal Jan 28, 2026
5e69be7
fix
BasselBlal Jan 28, 2026
b91d9ee
fix
BasselBlal Jan 28, 2026
07c9a96
fix
BasselBlal Jan 28, 2026
97dae0e
fix
BasselBlal Jan 28, 2026
0788b6c
fix
BasselBlal Jan 28, 2026
d2e161f
fix
BasselBlal Jan 28, 2026
31cbe1b
fix
BasselBlal Jan 28, 2026
0662eed
fix
BasselBlal Jan 28, 2026
80655f7
fix
BasselBlal Jan 28, 2026
0c9f68a
fix
BasselBlal Jan 28, 2026
5c0fbcf
fix
BasselBlal Jan 28, 2026
6becc2f
fix
BasselBlal Jan 28, 2026
d3fd0ca
fix
BasselBlal Jan 28, 2026
59f6aba
fix
BasselBlal Jan 28, 2026
e48db90
fix
bassel-blal Jan 28, 2026
3b24462
fix
BasselBlal Jan 28, 2026
9d47d0c
fix
BasselBlal Jan 28, 2026
6684a3d
fix
BasselBlal Jan 28, 2026
03a3812
fix
BasselBlal Jan 28, 2026
cc0d074
fix
BasselBlal Jan 28, 2026
ddfc295
Update
BasselBlal Jan 29, 2026
1a394b6
Update
BasselBlal Jan 29, 2026
89c54c0
Update
BasselBlal Jan 29, 2026
ac6b9e9
Update
BasselBlal Jan 29, 2026
f4a07c9
Update
BasselBlal Jan 29, 2026
fc2f8d6
Update
BasselBlal Jan 29, 2026
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
46 changes: 42 additions & 4 deletions cli/src/execution/parallel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { basename, join } from "node:path";
import simpleGit from "simple-git";
import { PROGRESS_FILE, RALPHY_DIR } from "../config/loader.ts";
import { logTaskProgress } from "../config/writer.ts";
Expand Down Expand Up @@ -27,7 +27,15 @@ import { clearDeferredTask, recordDeferredTask } from "./deferred.ts";
import { buildParallelPrompt } from "./prompt.ts";
import { isRetryableError, withRetry } from "./retry.ts";
import { commitSandboxChanges } from "./sandbox-git.ts";
import { cleanupSandbox, createSandbox, getModifiedFiles, getSandboxBase } from "./sandbox.ts";
import {
cleanupSandbox,
createSandbox,
DEFAULT_IGNORED,
getModifiedFiles,
getSandboxBase,
isIgnored,
matchesPattern,
} from "./sandbox.ts";
import type { ExecutionOptions, ExecutionResult } from "./sequential.ts";

interface ParallelAgentResult {
Expand Down Expand Up @@ -459,10 +467,40 @@ export async function runParallel(
if (!failureReason && aiResult?.success && agentUsedSandbox && worktreeDir) {
try {
const modifiedFiles = await getModifiedFiles(worktreeDir, workDir);
if (modifiedFiles.length > 0) {
const filteredFiles = modifiedFiles.filter((f) => {

if (f.trim() === "") {
return false;
}

const normalized = f.replace(/\\/g, "/");

// Check against all default ignore patterns
for (const pattern of DEFAULT_IGNORED) {
if (pattern.endsWith("/")) {
const dir = pattern.slice(0, -1);
// Check for exact directory match or if path starts with directory/
if (normalized === dir || normalized.startsWith(pattern)) {
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
logDebug(`Agent ${agentNum}: Filtered infrastructure file: ${f}`);
return false;
}
} else {
Comment thread
BasselBlal marked this conversation as resolved.
// File ignores: check basename
// e.g. "nul" matches "src/foo/nul"
const baseName = normalized.split("/").pop() || "";
Comment thread
BasselBlal marked this conversation as resolved.
if (matchesPattern(baseName, pattern, false)) {
Comment thread
BasselBlal marked this conversation as resolved.
logDebug(`Agent ${agentNum}: Filtered ignored file: ${f}`);
return false;
}
Comment thread
BasselBlal marked this conversation as resolved.
}
}

return true;
});
Comment thread
BasselBlal marked this conversation as resolved.
Comment thread
BasselBlal marked this conversation as resolved.
Comment thread
BasselBlal marked this conversation as resolved.
if (filteredFiles.length > 0) {
const commitResult = await commitSandboxChanges(
workDir,
modifiedFiles,
filteredFiles,
worktreeDir,
task.title,
agentNum,
Expand Down
2 changes: 0 additions & 2 deletions cli/src/execution/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,6 @@ export function buildParallelPrompt(options: ParallelPromptOptions): string {
step++;
}

instructions.push(`${step}. Update ${progressFile} with what you did`);
step++;
if (allowCommit) {
instructions.push(`${step}. Commit your changes with a descriptive message`);
} else {
Expand Down
17 changes: 15 additions & 2 deletions cli/src/execution/sandbox-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,21 @@ export async function commitSandboxChanges(
}
}

// Stage all modified files
await git.add(modifiedFiles);
// Filter out files ignored by .gitignore to prevent "path is ignored" errors
const ignored = (await git.checkIgnore(modifiedFiles)) || [];
const filesToAdd = modifiedFiles.filter((f) => !ignored.includes(f));

if (filesToAdd.length === 0) {
logDebug(`Agent ${agentNum}: No files to add (all were ignored)`);
return {
success: true,
branchName,
filesCommitted: 0,
};
}

// Stage modified files
await git.add(filesToAdd);

// Commit
const commitMessage = `feat: ${taskName}\n\nAutomated commit by Ralphy agent ${agentNum}`;
Expand Down
130 changes: 130 additions & 0 deletions cli/src/execution/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it } from "bun:test";
import { matchesPattern, isIgnored } from "./sandbox.ts";

describe("matchesPattern", () => {
describe("directory match (pattern/)", () => {
it("should match directory when isDirectory is true or undefined", () => {
expect(matchesPattern("node_modules", "node_modules/")).toBe(true);
expect(matchesPattern("node_modules", "node_modules/", true)).toBe(true);
expect(matchesPattern(".ralphy", ".ralphy/", true)).toBe(true);
});

it("should NOT match file when isDirectory is false", () => {
expect(matchesPattern("node_modules", "node_modules/", false)).toBe(false);
expect(matchesPattern(".ralphy", ".ralphy/", false)).toBe(false);
});
});

describe("exact match", () => {
it("should match exact filenames", () => {
expect(matchesPattern("node_modules", "node_modules")).toBe(true);
expect(matchesPattern(".ralphy", ".ralphy")).toBe(true);
});

it("should not match different filenames", () => {
expect(matchesPattern("node_modules", "vendor")).toBe(false);
});
});

describe("suffix match (*.ext)", () => {
it("should match files ending with extension", () => {
expect(matchesPattern("debug.log", "*.log")).toBe(true);
expect(matchesPattern("error.log", "*.log")).toBe(true);
expect(matchesPattern("test.sqlite", "*.sqlite")).toBe(true);
});

it("should NOT match when dot is treated as regex wildcard", () => {
// This is the key test - "*.log" should NOT match "testXlog"
// because we use string comparison, not regex
expect(matchesPattern("testXlog", "*.log")).toBe(false);
expect(matchesPattern("test-log", "*.log")).toBe(false);
});

it("should not match files without the extension", () => {
expect(matchesPattern("logfile", "*.log")).toBe(false);
expect(matchesPattern("log", "*.log")).toBe(false);
});
});

describe("prefix match (prefix*)", () => {
it("should match files starting with prefix", () => {
expect(matchesPattern("test123", "test*")).toBe(true);
expect(matchesPattern("test.js", "test*")).toBe(true);
});

it("should NOT match when dot is treated as regex wildcard", () => {
// "test.*" as prefix should use string comparison
expect(matchesPattern("testXjs", "test.")).toBe(false);
});

it("should not match files without the prefix", () => {
expect(matchesPattern("mytest", "test*")).toBe(false);
});
});

describe("tree match (dir/**)", () => {
it("should match all files under directory", () => {
expect(matchesPattern("src/foo.js", "src/**")).toBe(true);
expect(matchesPattern("src/nested/bar.ts", "src/**")).toBe(true);
});

it("should not match files outside directory", () => {
expect(matchesPattern("lib/foo.js", "src/**")).toBe(false);
});
});

describe("middle wildcard (test.*.js)", () => {
it("should match with proper regex escaping", () => {
expect(matchesPattern("test.foo.js", "test.*.js")).toBe(true);
expect(matchesPattern("test.bar.js", "test.*.js")).toBe(true);
});

it("should escape dots properly - not match when dot is wildcard", () => {
// "test.*.js" should NOT match "testXfooXjs" because dots are escaped
expect(matchesPattern("testXfooXjs", "test.*.js")).toBe(false);
});

it("should escape other metacharacters", () => {
// Pattern with special chars should work correctly
expect(matchesPattern("file[1].txt", "file[*].txt")).toBe(true);
expect(matchesPattern("file(test).log", "file(*).log")).toBe(true);
});
});
});

describe("isIgnored", () => {
it("should return true if any pattern matches", () => {
expect(isIgnored(".ralphy", [".ralphy-sandboxes", ".ralphy"])).toBe(true);
expect(isIgnored("debug.log", ["*.log", "*.sqlite"])).toBe(true);
});

it("should return false if no pattern matches", () => {
expect(isIgnored("src", [".ralphy", "node_modules"])).toBe(false);
});

it("should handle DEFAULT_IGNORED patterns correctly", () => {
const DEFAULT_IGNORED = [
".ralphy-sandboxes/",
".ralphy-worktrees/",
".ralphy/",
"nul",
];

// Matches because strict=undefined allows directory patterns to match
// This simulates typical lenient behavior if type isn't known, or just directory check
expect(isIgnored(".ralphy", DEFAULT_IGNORED)).toBe(true);

// Should explicitly match if isDirectory=true
expect(isIgnored(".ralphy", DEFAULT_IGNORED, true)).toBe(true);

// Should NOT match if isDirectory=false
expect(isIgnored(".ralphy", DEFAULT_IGNORED, false)).toBe(false);

// Removed *.log from ignore list, so these should return false
expect(isIgnored("debug.log", DEFAULT_IGNORED)).toBe(false);

expect(isIgnored("nul", DEFAULT_IGNORED)).toBe(true);
expect(isIgnored("src", DEFAULT_IGNORED)).toBe(false);
expect(isIgnored("package.json", DEFAULT_IGNORED)).toBe(false);
});
});
Loading