feat(ui): use backend normalize method to validate user-entered branch names#12488
feat(ui): use backend normalize method to validate user-entered branch names#12488nshcr wants to merge 1 commit intogitbutlerapp:masterfrom
Conversation
|
@nshcr is attempting to deploy a commit to the GitButler Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
This PR refactors branch name validation in the frontend to use a backend API call instead of the frontend slugify function. The change allows users to create and rename branches using a wider range of characters (such as # and @) that are valid in Git ref names but were previously stripped by the frontend's restrictive character allowlist.
Changes:
- Replaced frontend
slugifyvalidation with async backendnormalizeBranchNameAPI calls - Added debounced validation (300ms) with loading state and error handling
- Updated all branch name input modals to use the new validation state
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/desktop/src/components/BranchNameTextbox.svelte | Replaced synchronous slugify with async backend normalization, added validation state tracking, loading spinner, and race condition protection |
| apps/desktop/src/components/CreateBranchModal.svelte | Added isBranchNameValid state and updated button disabled logic to use validation state instead of checking for non-empty input |
| apps/desktop/src/components/ChangedFilesContextMenu.svelte | Added isStashBranchNameValid state for stash-into-branch modal and updated button disabled logic |
| apps/desktop/src/components/BranchRenameModal.svelte | Added isBranchNameValid state and updated button disabled logic |
| apps/desktop/src/components/AddDependentBranchModal.svelte | Added isBranchNameValid state and updated button disabled logic |
| } finally { | ||
| if (value === inputValue) { | ||
| isValidating = false; | ||
| } | ||
| } |
There was a problem hiding this comment.
There's a potential race condition in the validation state management. When a user types quickly, multiple API calls can be in flight simultaneously. When an outdated API call completes, the finally block sets isValidating = false even though a newer validation is still pending. This could cause the spinner to briefly disappear and the button to become enabled prematurely. Consider tracking a unique identifier for each validation request or canceling previous requests when a new one starts.
There was a problem hiding this comment.
This race condition was resolved by adding a counter to the debounced function call and checking whether the counter has changed before actually updating the state, ensuring that no additional async calls are still in progress.
I believe this implementation is sound. Leaving this thread unresolved for further review.
…lidation Applied changes from PR review gitbutlerapp#12488: - Renamed onslugifiedvalue callback to onnormalizedvalue - Renamed slugifiedRefName variables to normalizedRefName across all components - Fixed race condition in validation state using validationCounter pattern Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…lidation Applied changes from PR review gitbutlerapp#12488: - Renamed onslugifiedvalue callback to onnormalizedvalue - Renamed slugifiedRefName variables to normalizedRefName across all components - Fixed race condition in validation state using validationCounter pattern Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
9a01610 to
3ae61c2
Compare
| } catch { | ||
| if (value === inputValue && currentValidation === validationCounter) { | ||
| normalizedResult = undefined; | ||
| onnormalizedvalue?.(undefined); | ||
| validationError = 'Invalid branch name'; | ||
| } |
There was a problem hiding this comment.
Consider logging the caught error for debugging purposes. Currently, when the normalization API call fails, the error is silently caught and only a generic 'Invalid branch name' message is shown. Logging the actual error would help diagnose issues with branch name validation. For example: catch (err) { console.error('Branch name normalization failed:', err); ... }
There was a problem hiding this comment.
Since validation runs dynamically as the user types, I think it's acceptable to silently catch the error and display a generic error message (IPC errors are printed to the console by default and it's difficult to directly retrieve the message from the error object).
Leaving this conversation as is.
| @@ -91,22 +92,22 @@ | |||
| await createNewStack({ | |||
| projectId, | |||
| branch: { | |||
| name: slugifiedRefName, | |||
| name: normalizedRefName, | |||
| // If addToLeftmost is true, place at position 0 (leftmost) | |||
| // Otherwise, leave undefined to append to the right | |||
| order: $addToLeftmost ? 0 : undefined | |||
| } | |||
| }); | |||
| createRefModal?.close(); | |||
There was a problem hiding this comment.
The stack creation path should include a defensive check for normalizedRefName similar to the dependent branch path on line 103. While the submit button is disabled when the branch name is invalid, adding this check would make the code more defensive and consistent with other similar functions in the codebase (e.g., AddDependentBranchModal line 25, BranchRenameModal line 44, and the dependent branch case on line 103).
There was a problem hiding this comment.
I don't think the defensive check here is necessary. The createNewStack method accepts a name of type string | undefined, and normalizedRefName conforms to that type definition. If something goes wrong, the backend will panic.
Leaving this conversation as is.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ae61c2656
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Thanks so much for contributing this fix! This seems to be a follow-up to one of my recent modifications to this code where I was thinking the same - the backend should be in control of handling this and act as source of truth. I tried it locally and could see it working, but found the debouncing to make it feel a little sluggish. With this patch it felt alright. diff --git a/apps/desktop/src/components/BranchNameTextbox.svelte b/apps/desktop/src/components/BranchNameTextbox.svelte
index 1b7fcf79b6..ecd31ac3d5 100644
--- a/apps/desktop/src/components/BranchNameTextbox.svelte
+++ b/apps/desktop/src/components/BranchNameTextbox.svelte
@@ -78,7 +78,7 @@
isValidating = false;
}
}
- }, 300);
+ }, 100);
$effect(() => {
debouncedNormalize(value || '');With that said, I don't know enough to be able to merge it, so let's have a separate review. |
|
Recording the issue pointed out during the Codex review (#12488 (comment)), along with the problem in the current (uncommitted) fix. Steps to reproduce: Screen.Recording.2026-02-23.at.14.58.55.movA potential fix, and the issues with that fix: Screen.Recording.2026-02-23.at.14.59.37.movSpecific fix implementation code: diff --git a/apps/desktop/src/components/BranchNameTextbox.svelte b/apps/desktop/src/components/BranchNameTextbox.svelte
index 1b7fcf79b..6e865f005 100644
--- a/apps/desktop/src/components/BranchNameTextbox.svelte
+++ b/apps/desktop/src/components/BranchNameTextbox.svelte
@@ -30,7 +30,11 @@
let normalizedResult = $state<{ fromValue: string; normalized: string } | undefined>();
const isValidState = $derived(
- !isValidating && !validationError && !!value && !!normalizedResult?.normalized
+ !isValidating &&
+ !validationError &&
+ !!value &&
+ !!normalizedResult?.normalized &&
+ normalizedResult.fromValue === value
);
$effect(() => {
onvalidationchange?.(isValidState);
@@ -78,7 +82,7 @@
isValidating = false;
}
}
- }, 300);
+ }, 100);
$effect(() => {
debouncedNormalize(value || ''); |
|
I am glad you could reproduce the race and have a fix for it! Also, I put it back to draft while there are conflicts due to the style changes. Please feel free to take it out of draft once it can get a final review. |
…h names refactor: rename slugified to normalized and fix race condition in validation Applied changes from PR review gitbutlerapp#12488: - Renamed onslugifiedvalue callback to onnormalizedvalue - Renamed slugifiedRefName variables to normalizedRefName across all components - Fixed race condition in validation state using validationCounter pattern Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
3ae61c2 to
4ff0f76
Compare
| $effect(() => { | ||
| onslugifiedvalue?.(slugifiedName); | ||
| debouncedNormalize(value || ""); | ||
| }); |
There was a problem hiding this comment.
The $effect that calls debouncedNormalize doesn't provide a cleanup function to clear pending timeouts when the component unmounts or when the value changes. This could lead to memory leaks or stale API calls completing after the component is destroyed.
Based on the pattern used elsewhere in the codebase (e.g., UnassignedViewForgePrompt.svelte:47), the effect should return a cleanup function. However, the current debounce utility doesn't expose a way to cancel pending calls.
Consider either:
- Enhancing the
debounceutility to return an object with both the debounced function and acancel()method, or - Creating a custom debounced effect that manages its own timeout state within the component and returns a cleanup function that clears the timeout.
There was a problem hiding this comment.
The effect here doesn't need cleanup. The debounced function already has an internal mechanism to ensure that only the last invocation is executed, and the code also prevents concurrent state updates.
Adding a cleanup function would only increase code complexity in exchange for eliminating just one extra 20ms invocation.
| @@ -145,7 +146,8 @@ | |||
| id={ElementId.NewBranchNameInput} | |||
| value={createRefName} | |||
There was a problem hiding this comment.
The value prop should be a two-way binding using bind:value={createRefName} instead of just value={createRefName}. Without the bind: directive, the createRefName variable won't update as the user types, and the component will only display the initial value fetched from the backend. This is inconsistent with other usages of BranchNameTextbox in the codebase (e.g., BranchRenameModal.svelte:54, AddDependentBranchModal.svelte:54, ChangedFilesContextMenu.svelte:652).
| value={createRefName} | |
| bind:value={createRefName} |
There was a problem hiding this comment.
This variable is mainly used to provide an initial value when opening the modal for creating a new branch. It does not participate in subsequent updates, so two-way binding isn't necessary.
|
I've included the fix mentioned in my previous comment in the latest commit. |
🧢 Changes
Before:
Screen.Recording.2026-02-21.at.12.07.02.mov
After:
Screen.Recording.2026-02-21.at.21.10.04.mov
Screen.Recording.2026-02-21.at.21.13.45.mov
This PR updates the frontend logic for branch name normalization when creating or renaming branches via modal dialogs. Instead of relying on the frontend
slugifyfunction, it now calls a backend API to perform normalized form input validation, and prevents submission early if the branch name is invalid.(Since the submit handler also invokes the same
normalizefunction for a second check, failing to block invalid input during validation would result in an error being thrown on submit.)References:
slugifyfunction:gitbutler/packages/ui/src/lib/utils/string.ts
Lines 39 to 47 in 36b7994
normalizefunction:gitbutler/crates/but-core/src/branch/normalize.rs
Lines 4 to 6 in 36b7994
☕️ Reasoning
This change primarily helps allow users to create or rename branches using characters beyond just alphanumeric ones, aligning the behavior with the existing logic used when renaming a branch by clicking the branch header (not using
slugify, allowing a broader range of characters).With this unified approach, branch name normalization will rely solely on backend functions. This makes it easier to extend normalization logic in the future from the backend side (e.g., filtering special characters, enforcing lowercase rules, etc.).
🎫 Affected issues
#in branch names during frontend slugification #12322This PR addresses the broader problem targeted in #12322. Instead of continuously modifying the frontend
slugifyfunction to support more special characters, it shifts the responsibility to backend-based normalization.Moving branch name normalization entirely to the backend will also make it easier to enforce an all-lowercase rule in the future. This would primarily be a backend-driven feature, with the frontend simply reflecting the consistent behavior in the UI.