diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..2b48000cebd0 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -8,6 +8,7 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + VcsStatusLocalResult, } from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); @@ -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", () => { @@ -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", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index c5a5825e193e..a7d99c105c01 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -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 @@ -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), }); @@ -220,7 +219,7 @@ const VcsStatusLocalShape = { workingTree: Schema.Struct({ files: Schema.Array( Schema.Struct({ - path: TrimmedNonEmptyStringSchema, + path: GitFilePath, insertions: NonNegativeInt, deletions: NonNegativeInt, }),