Skip to content

Commit 2a12eaf

Browse files
myftijaTrigger.dev RepoOps
authored andcommitted
feat(webapp): add ignored paths to the Git settings
Add "Ignored paths" to the Git settings on the Integrations page: globs relative to the repository root, one per line (for example `docs/**`). A push or pull request update that only changes matching files doesn't deploy. It's empty by default and doesn't apply while the Vercel integration is connected. Self-hosted instances can change the limits with `GITHUB_IGNORED_PATHS_LIMIT` (100 paths) and `GITHUB_IGNORED_PATH_MAX_LENGTH` (256 characters). Mono-RevId: 74d2f91ff3a66a95a941ccb670df04357a78498d
1 parent 111d816 commit 2a12eaf

19 files changed

Lines changed: 1230 additions & 69 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Add "Ignored paths" to the Git settings: pushes and pull request updates that only change files matching these patterns no longer create a deployment. Ignored paths don't apply while the Vercel integration is connected.

‎apps/webapp/app/components/code/TextEditor.tsx‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { EditorView, lineNumbers } from "@codemirror/view";
33
import { CheckIcon, ClipboardIcon } from "@heroicons/react/20/solid";
44
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
55
import { useCodeMirror } from "@uiw/react-codemirror";
6-
import { useCallback, useEffect, useRef, useState } from "react";
6+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
77
import { cn } from "~/utils/cn";
88
import { Button } from "../primitives/Buttons";
99
import { getEditorSetup } from "./codeMirrorSetup";
@@ -27,23 +27,30 @@ export function TextEditor(opts: TextEditorProps) {
2727
autoFocus,
2828
showCopyButton = true,
2929
additionalActions,
30+
extensions: extraExtensions,
3031
} = opts;
3132

32-
// Don't use default line numbers from setup — add our own with proper sizing
33-
const extensions = getEditorSetup(false);
34-
extensions.push(EditorView.lineWrapping);
35-
extensions.push(
36-
lineNumbers({
37-
formatNumber: (n) => String(n),
38-
})
39-
);
40-
extensions.push(
41-
EditorView.theme({
42-
".cm-lineNumbers": {
43-
minWidth: "40px",
44-
},
45-
})
46-
);
33+
// New identities make CodeMirror reconfigure and mount fresh theme styles.
34+
const extensions = useMemo(() => {
35+
// Don't use default line numbers from setup — add our own with proper sizing
36+
const setup = getEditorSetup(false);
37+
setup.push(EditorView.lineWrapping);
38+
setup.push(
39+
lineNumbers({
40+
formatNumber: (n) => String(n),
41+
})
42+
);
43+
setup.push(
44+
EditorView.theme({
45+
".cm-lineNumbers": {
46+
minWidth: "40px",
47+
},
48+
})
49+
);
50+
setup.push(...(extraExtensions ?? []));
51+
return setup;
52+
}, [extraExtensions]);
53+
const theme = useMemo(() => darkTheme(), []);
4754

4855
const editor = useRef<HTMLDivElement>(null);
4956
const settings: Omit<UseCodeMirror, "onBlur"> = {
@@ -55,7 +62,7 @@ export function TextEditor(opts: TextEditorProps) {
5562
contentEditable: !readOnly,
5663
value: defaultValue,
5764
autoFocus,
58-
theme: darkTheme(),
65+
theme,
5966
indentWithTab: false,
6067
basicSetup: false,
6168
onChange,

‎apps/webapp/app/components/deployments/GitHubDeploymentOnboarding.test.ts‎

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
1212
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
1313
import { deployNowRequestError, GitHubDeploymentOnboarding } from "./GitHubDeploymentOnboarding";
1414
import { OnboardingDeploymentLogs } from "./OnboardingDeploymentLogs";
15+
import type { BuildFilters } from "~/v3/github";
1516

1617
function render(node: React.ReactNode) {
1718
const router = createMemoryRouter([
@@ -171,6 +172,7 @@ it("wires the real connected-repository form into the existing save endpoint", (
171172
connectedGitHubRepo: {
172173
branchTracking: { prod: { branch: "main" }, staging: { branch: "staging" } },
173174
previewDeploymentsEnabled: true,
175+
buildFilters: { ignorePaths: ["docs/**"] },
174176
createdAt: new Date("2026-09-15T00:00:00Z"),
175177
repository: {
176178
id: "repo",
@@ -198,6 +200,7 @@ it("wires the real connected-repository form into the existing save endpoint", (
198200
expect(html).toContain('name="previewDeploymentsEnabled"');
199201
expect(html).toContain('value="update-git-settings"');
200202
expect(html).toContain('value="/orgs/org/projects/project/env/prod/deployments"');
203+
expect(html).not.toContain('name="ignorePaths"');
201204
expect(html).not.toContain("Disconnect");
202205
expect(html).not.toContain("Upgrade");
203206
expect((html.match(/ disabled=""/g) ?? []).length).toBeGreaterThanOrEqual(autosave ? 3 : 4);
@@ -235,32 +238,41 @@ it("gives Preview parent and disabled branch environments actionable guidance",
235238
).toContain("Enable preview deployments in GitHub settings to deploy this branch");
236239
});
237240

241+
function settingsForm(
242+
props: Partial<ComponentProps<typeof ConnectedGitHubRepoForm>> = {},
243+
buildFilters?: BuildFilters
244+
) {
245+
return render(
246+
createElement(ConnectedGitHubRepoForm, {
247+
connectedGitHubRepo: {
248+
branchTracking: { prod: { branch: "main" }, staging: { branch: "staging" } },
249+
previewDeploymentsEnabled: true,
250+
buildFilters,
251+
createdAt: new Date("2026-09-15T00:00:00Z"),
252+
repository: {
253+
id: "repo",
254+
name: "tasks",
255+
fullName: "org/tasks",
256+
private: true,
257+
htmlUrl: "https://example.test/org/tasks",
258+
},
259+
},
260+
previewEnvironmentEnabled: true,
261+
stagingEnvironmentEnabled: true,
262+
organizationSlug: "org",
263+
projectSlug: "project",
264+
environmentSlug: "prod",
265+
billingPath: "/billing",
266+
redirectUrl: "/orgs/org/projects/project/env/prod/deployments",
267+
ignorePathLimits: { maxPaths: 100, maxLength: 256 },
268+
...props,
269+
})
270+
);
271+
}
272+
238273
it("keeps the legacy connected-repository form on its original layout and explicit Save", () => {
239274
for (const canManageGithub of [false, true]) {
240-
const html = render(
241-
createElement(ConnectedGitHubRepoForm, {
242-
connectedGitHubRepo: {
243-
branchTracking: { prod: { branch: "main" }, staging: { branch: "staging" } },
244-
previewDeploymentsEnabled: true,
245-
createdAt: new Date("2026-09-15T00:00:00Z"),
246-
repository: {
247-
id: "repo",
248-
name: "tasks",
249-
fullName: "org/tasks",
250-
private: true,
251-
htmlUrl: "https://example.test/org/tasks",
252-
},
253-
},
254-
previewEnvironmentEnabled: true,
255-
stagingEnvironmentEnabled: true,
256-
organizationSlug: "org",
257-
projectSlug: "project",
258-
environmentSlug: "prod",
259-
billingPath: "/billing",
260-
redirectUrl: "/orgs/org/projects/project/env/prod/deployments",
261-
canManageGithub,
262-
})
263-
);
275+
const html = settingsForm({ canManageGithub });
264276
expect(html).toContain('action="/resources/orgs/org/projects/project/env/prod/github"');
265277
expect(html).toContain('name="productionBranch"');
266278
expect(html).toContain('name="stagingBranch"');
@@ -277,6 +289,22 @@ it("keeps the legacy connected-repository form on its original layout and explic
277289
}
278290
});
279291

292+
it("lists stored ignored paths after the branch fields and submits them one per line", () => {
293+
const html = settingsForm({}, { ignorePaths: ["docs/**", "*.md"] });
294+
expect(html).toContain('type="hidden" name="ignorePaths" value="docs/**\n*.md"');
295+
expect(html.indexOf('name="ignorePaths"')).toBeGreaterThan(
296+
html.indexOf('name="previewDeploymentsEnabled"')
297+
);
298+
expect(html.indexOf('name="ignorePaths"')).toBeLessThan(html.indexOf(">Save<"));
299+
expect(settingsForm()).toContain('type="hidden" name="ignorePaths" value=""');
300+
});
301+
302+
it("says ignored paths don't apply only while a Vercel integration is connected", () => {
303+
const notice = "apply while the Vercel integration is connected";
304+
expect(settingsForm({ vercelIntegrationConnected: true })).toContain(notice);
305+
expect(settingsForm({ vercelIntegrationConnected: false })).not.toContain(notice);
306+
});
307+
280308
it("tells the user a missing branch has to be pushed instead of asking them to retry", () => {
281309
const missing = 'The branch "test" doesn\'t exist in acme/app. Push it to GitHub, then deploy.';
282310
expect(deployNowRequestError({ code: "BRANCH_NOT_FOUND", error: missing })).toBe(missing);
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// @vitest-environment jsdom
2+
// Component test beside its component, like the other deployments tests: typing and folding have no route seam.
3+
import { EditorView } from "@codemirror/view";
4+
import { createElement } from "react";
5+
import { createRoot, type Root } from "react-dom/client";
6+
import { act } from "react-dom/test-utils";
7+
import { afterEach, describe, expect, it, vi } from "vitest";
8+
import { OperatingSystemContextProvider } from "../primitives/OperatingSystemProvider";
9+
import { ShortcutsProvider } from "../primitives/ShortcutsProvider";
10+
import { IgnoredPathsEditor } from "./IgnoredPathsEditor";
11+
12+
globalThis.ResizeObserver ??= class {
13+
observe() {}
14+
unobserve() {}
15+
disconnect() {}
16+
};
17+
18+
let container: HTMLDivElement;
19+
let root: Root;
20+
21+
function mount(defaultPatterns: string[] = [], limits = { maxPaths: 100, maxLength: 256 }) {
22+
const onChange = vi.fn();
23+
container = document.createElement("div");
24+
document.body.append(container);
25+
root = createRoot(container);
26+
act(() => {
27+
root.render(
28+
createElement(
29+
OperatingSystemContextProvider,
30+
{ platform: "mac" },
31+
createElement(
32+
ShortcutsProvider,
33+
null,
34+
createElement(IgnoredPathsEditor, {
35+
name: "ignorePaths",
36+
defaultPatterns,
37+
limits,
38+
onChange,
39+
})
40+
)
41+
)
42+
);
43+
});
44+
return onChange;
45+
}
46+
47+
afterEach(() => {
48+
act(() => root.unmount());
49+
container.remove();
50+
});
51+
52+
function editor() {
53+
return EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
54+
}
55+
56+
function edit(text: string) {
57+
const view = editor();
58+
act(() => {
59+
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
60+
});
61+
}
62+
63+
function submitted() {
64+
return container.querySelector<HTMLInputElement>('input[name="ignorePaths"]')!.value;
65+
}
66+
67+
function button(label: string) {
68+
return container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
69+
}
70+
71+
function tester() {
72+
return container.querySelector<HTMLInputElement>('input[aria-label="Path to test"]');
73+
}
74+
75+
function type(value: string) {
76+
act(() => {
77+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
78+
tester(),
79+
value
80+
);
81+
tester()!.dispatchEvent(new Event("input", { bubbles: true }));
82+
});
83+
}
84+
85+
function press(key: string) {
86+
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
87+
act(() => {
88+
tester()!.dispatchEvent(event);
89+
});
90+
return event.defaultPrevented;
91+
}
92+
93+
function verdict() {
94+
return [...container.querySelector('[role="status"]')!.children].map((part) => part.textContent);
95+
}
96+
97+
function errors() {
98+
return [...container.querySelectorAll("li")].map((item) => item.textContent);
99+
}
100+
101+
describe("IgnoredPathsEditor", () => {
102+
it("folds more than three saved paths and expands into the editor", () => {
103+
const paths = "docs/**\napps/website/**\n**/*.md\n.github/**\nexamples/**";
104+
mount(paths.split("\n"));
105+
106+
expect(button("Expand")).not.toBeNull();
107+
expect(submitted()).toBe(paths);
108+
109+
act(() => button("Expand")!.click());
110+
expect(editor().hasFocus).toBe(true);
111+
expect(button("Collapse")).not.toBeNull();
112+
113+
act(() => button("Collapse")!.click());
114+
expect(button("Expand")).not.toBeNull();
115+
});
116+
117+
it("starts with a note that every push deploys and reports typed paths", () => {
118+
const onChange = mount();
119+
expect(container.textContent).toContain(
120+
"No paths yet, so every push to a tracked branch deploys."
121+
);
122+
expect(button("Expand")).toBeNull();
123+
124+
edit(" docs/** \n\n*.md\n");
125+
126+
expect(submitted()).toBe(" docs/** \n\n*.md\n");
127+
expect(onChange).toHaveBeenLastCalledWith(["docs/**", "*.md"], true);
128+
expect(container.textContent).not.toContain("No paths yet");
129+
});
130+
131+
it("lists each invalid line and marks the editor invalid", () => {
132+
const onChange = mount();
133+
edit("docs/**\n!src/**\nexamples/\n**/*.{md,mdx}");
134+
135+
expect(errors()).toEqual([
136+
"Line 2: Negated patterns aren't supported. List only the paths to ignore.",
137+
"Line 3: Matches no files. Use examples/** for everything in the directory.",
138+
"Line 4: Braces aren't supported. Put each alternative on its own line.",
139+
]);
140+
expect(container.textContent).toContain("3 errors");
141+
expect(editor().contentDOM.getAttribute("aria-invalid")).toBe("true");
142+
expect(onChange).toHaveBeenLastCalledWith(
143+
["docs/**", "!src/**", "examples/", "**/*.{md,mdx}"],
144+
false
145+
);
146+
});
147+
148+
it("applies the configured path count and length limits", () => {
149+
const onChange = mount(["docs/**", "*.md"], { maxPaths: 2, maxLength: 10 });
150+
151+
edit("docs/**\n*.md\nexamples/**/*.ts");
152+
153+
expect(errors()).toEqual(["Line 3: Use at most 10 characters.", "Add at most 2 paths."]);
154+
expect(onChange).toHaveBeenLastCalledWith(["docs/**", "*.md", "examples/**/*.ts"], false);
155+
});
156+
157+
it("tests a path against the saved paths while folded", () => {
158+
mount(["docs/**", "apps/website/**", "**/*.md", ".github/**"]);
159+
act(() => button("Test a path")!.click());
160+
expect(document.activeElement).toBe(tester());
161+
expect(button("Expand")).not.toBeNull();
162+
163+
type("apps/website/src/index.tsx");
164+
expect(verdict()).toEqual(["Ignored", "by apps/website/**"]);
165+
166+
type("src/index.ts");
167+
expect(verdict()).toEqual(["Deploys", "no path matches"]);
168+
169+
expect(press("Enter")).toBe(true);
170+
press("Escape");
171+
expect(tester()).toBeNull();
172+
expect(document.activeElement).toBe(button("Test a path"));
173+
174+
act(() => button("Test a path")!.click());
175+
expect(tester()!.value).toBe("");
176+
});
177+
178+
it("updates the verdict when the paths change", () => {
179+
mount(["docs/**", "*.md"]);
180+
act(() => button("Test a path")!.click());
181+
type("docs/guide.md");
182+
expect(verdict()).toEqual(["Ignored", "by docs/**"]);
183+
184+
edit("src/**\n**/*.md");
185+
expect(verdict()).toEqual(["Ignored", "by **/*.md"]);
186+
187+
edit("src/**\n!docs/**");
188+
expect(verdict()).toEqual(["Deploys", "no path matches"]);
189+
});
190+
191+
it("offers the tester only once there is a valid path", () => {
192+
mount();
193+
expect(button("Test a path")).toBeNull();
194+
195+
edit("!src/**");
196+
expect(button("Test a path")).toBeNull();
197+
198+
edit("!src/**\ndocs/**");
199+
expect(button("Test a path")).not.toBeNull();
200+
201+
act(() => button("Test a path")!.click());
202+
edit("!src/**");
203+
expect(button("Close the path tester")).not.toBeNull();
204+
});
205+
});

0 commit comments

Comments
 (0)