Skip to content
Open
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
66 changes: 66 additions & 0 deletions packages/contracts/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
GitRunStackedActionResult,
GitRunStackedActionInput,
GitResolvePullRequestResult,
VcsStatusLocalResult,
} from "./git.ts";

const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput);
Expand All @@ -20,6 +21,7 @@ const decodePreparePullRequestThreadResult = Schema.decodeUnknownSync(
const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput);
const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult);
const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
const decodeVcsStatusLocalResult = Schema.decodeUnknownSync(VcsStatusLocalResult);

describe("VcsCreateWorktreeInput", () => {
it("accepts omitted newRefName for existing-refName worktrees", () => {
Expand Down Expand Up @@ -125,6 +127,70 @@ describe("GitRunStackedActionInput", () => {
expect(parsed.actionId).toBe("action-1");
expect(parsed.action).toBe("create_pr");
});

it("preserves leading and trailing whitespace in file paths", () => {
const parsed = decodeRunStackedActionInput({
actionId: "action-1",
cwd: "/repo",
action: "commit",
filePaths: [" leading.txt", "trailing.txt "],
});

expect(parsed.filePaths).toEqual([" leading.txt", "trailing.txt "]);
});

it("rejects an empty file path", () => {
expect(() =>
decodeRunStackedActionInput({
actionId: "action-1",
cwd: "/repo",
action: "commit",
filePaths: [""],
}),
).toThrow();
});
});

describe("VcsStatusLocalResult", () => {
it("preserves leading and trailing whitespace in working-tree paths", () => {
const parsed = decodeVcsStatusLocalResult({
isRepo: true,
hasPrimaryRemote: true,
isDefaultRef: false,
refName: "main",
hasWorkingTreeChanges: true,
workingTree: {
files: [
{ path: " leading.txt", insertions: 1, deletions: 0 },
{ path: "trailing.txt ", insertions: 0, deletions: 1 },
],
insertions: 1,
deletions: 1,
},
});

expect(parsed.workingTree.files.map((file) => file.path)).toEqual([
" leading.txt",
"trailing.txt ",
]);
});

it("rejects an empty working-tree path", () => {
expect(() =>
decodeVcsStatusLocalResult({
isRepo: true,
hasPrimaryRemote: true,
isDefaultRef: false,
refName: "main",
hasWorkingTreeChanges: true,
workingTree: {
files: [{ path: "", insertions: 0, deletions: 0 }],
insertions: 0,
deletions: 0,
},
}),
).toThrow();
});
});

describe("GitRunStackedActionResult", () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/contracts/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceC
import { VcsDriverKind } from "./vcs.ts";

const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
const GitFilePath = Schema.String.check(Schema.isNonEmpty());
const GIT_LIST_BRANCHES_MAX_LIMIT = 200;

// Domain Types
Expand Down Expand Up @@ -116,9 +117,7 @@ export const GitRunStackedActionInput = Schema.Struct({
action: GitStackedAction,
commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
featureBranch: Schema.optional(Schema.Boolean),
filePaths: Schema.optional(
Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
),
filePaths: Schema.optional(Schema.Array(GitFilePath).check(Schema.isMinLength(1))),
/** The thread the action runs beside; a pull request it creates is linked to it. */
threadId: Schema.optional(ThreadId),
});
Expand Down Expand Up @@ -220,7 +219,7 @@ const VcsStatusLocalShape = {
workingTree: Schema.Struct({
files: Schema.Array(
Schema.Struct({
path: TrimmedNonEmptyStringSchema,
path: GitFilePath,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/git.ts:222

Repositories containing leading.txt or trailing.txt still report leading.txt or trailing.txt, so selecting the reported path does not identify the changed file and the targeted commit cannot stage it. parseNumstatEntries and parsePorcelainPath trim rawPath before it reaches GitFilePath; preserve Git's path bytes in those producers instead of trimming filename whitespace.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/git.ts around line 222:

Repositories containing ` leading.txt` or `trailing.txt ` still report `leading.txt` or `trailing.txt`, so selecting the reported path does not identify the changed file and the targeted commit cannot stage it. `parseNumstatEntries` and `parsePorcelainPath` trim `rawPath` before it reaches `GitFilePath`; preserve Git's path bytes in those producers instead of trimming filename whitespace.

insertions: NonNegativeInt,
deletions: NonNegativeInt,
}),
Expand Down
Loading