diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 0df96fa97abf..4a285c613621 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -289,6 +289,15 @@ describe('computeBranchName', () => { 'jdoe/feature' ) }) + + it('omits a git username whose lowercase form git rejects', () => { + expect(computeBranchName('feature', { branchPrefix: 'git-username' }, 'Alice.LOCK')).toBe( + 'feature' + ) + expect( + computeValidatedBranchName('feature', { branchPrefix: 'git-username' }, 'Alice.LOCK') + ).toBe('feature') + }) }) describe('getConfiguredBranchPrefix', () => { diff --git a/src/renderer/src/components/settings/BranchPrefixFeedback.tsx b/src/renderer/src/components/settings/BranchPrefixFeedback.tsx index 2f892dba0507..ef3d39be1e10 100644 --- a/src/renderer/src/components/settings/BranchPrefixFeedback.tsx +++ b/src/renderer/src/components/settings/BranchPrefixFeedback.tsx @@ -1,12 +1,20 @@ import type { ReactNode } from 'react' -import { getBranchPrefixIssue, normalizeBranchPrefix } from '../../../../shared/branch-prefix' +import { + getBranchPrefixIssue, + normalizeBranchPrefix, + type GitUsernamePrefixOmission +} from '../../../../shared/branch-prefix' import { translate } from '@/i18n/i18n' type BranchPrefixFeedbackProps = { rawPrefix: string + omission?: GitUsernamePrefixOmission | null } -export function BranchPrefixFeedback({ rawPrefix }: BranchPrefixFeedbackProps): ReactNode { +export function BranchPrefixFeedback({ + rawPrefix, + omission = null +}: BranchPrefixFeedbackProps): ReactNode { const issue = getBranchPrefixIssue(rawPrefix) const normalized = normalizeBranchPrefix(rawPrefix) @@ -30,6 +38,15 @@ export function BranchPrefixFeedback({ rawPrefix }: BranchPrefixFeedbackProps): )} ) + } else if (omission === 'lowercase-lock') { + message = ( + + {translate( + 'auto.components.settings.BranchPrefixFeedback.c478cf4ec7', + 'No prefix will be applied because lowercasing this username ends in .lock, which Git rejects.' + )} + + ) } else if (rawPrefix.trim()) { message = ( diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts index df0158497f0e..547a45825f29 100644 --- a/src/renderer/src/components/settings/GitPane.test.ts +++ b/src/renderer/src/components/settings/GitPane.test.ts @@ -331,10 +331,26 @@ describe('GitPane branch prefix feedback', () => { it('warns in git-username mode when the displayed username is invalid', () => { const html = renderBranchPrefixGitPane(gitUsernamePrefixSettings(), 'team x') expect(html).toContain('Prefix cannot contain spaces') + expect(html).not.toContain('which Git rejects') }) it('previews in git-username mode when the displayed username is valid', () => { const html = renderBranchPrefixGitPane(gitUsernamePrefixSettings(), 'jdoe/') expect(html).toContain('jdoe/feature') }) + + it('previews the lowercased git username that new branches use', () => { + const html = renderBranchPrefixGitPane(gitUsernamePrefixSettings(), 'EurFelux') + expect(html).toContain('eurfelux/feature') + expect(html).not.toContain('EurFelux') + }) + + it('does not preview a git username whose lowercase form is not a valid ref', () => { + const html = renderBranchPrefixGitPane(gitUsernamePrefixSettings(), 'Alice.LOCK') + expect(html).toContain( + 'No prefix will be applied because lowercasing this username ends in .lock, which Git rejects.' + ) + expect(html).not.toContain('Alice.LOCK') + expect(html).not.toContain('alice.lock') + }) }) diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index f2812f280419..42bcb820de6b 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -9,6 +9,10 @@ import { Switch } from '../ui/switch' import { useAppStore } from '../../store' import { getGitPaneSearchEntries } from './git-search' import { SearchableSetting } from './SearchableSetting' +import { + gitUsernamePrefixOmission, + selectBranchPrefixInput +} from '../../../../shared/branch-prefix' import { BranchPrefixFeedback } from './BranchPrefixFeedback' import { matchesSettingsSearch } from './settings-search' import { AutoRenameBranchFromWorkSetting } from './AutoRenameBranchFromWorkSetting' @@ -171,7 +175,9 @@ export function GitPane({ } }, [settings.branchPrefixCustom]) const branchPrefixInputValue = - settings.branchPrefix === 'git-username' ? displayedGitUsername : customPrefixDraft + settings.branchPrefix === 'git-username' + ? (selectBranchPrefixInput(settings, displayedGitUsername || null) ?? '') + : customPrefixDraft const visibleSections = [ matchesSettingsSearch(searchQuery, { @@ -235,17 +241,33 @@ export function GitPane({ }} placeholder={ settings.branchPrefix === 'git-username' - ? translate( - 'auto.components.settings.GitPane.aefa1ecb59', - 'No git username configured' - ) + ? displayedGitUsername.trim() + ? translate( + 'auto.components.settings.BranchPrefixFeedback.808f9a726e', + 'No prefix will be applied' + ) + : translate( + 'auto.components.settings.GitPane.aefa1ecb59', + 'No git username configured' + ) : translate('auto.components.settings.GitPane.b559bf9899', 'e.g. feature') } className="max-w-xs" readOnly={settings.branchPrefix === 'git-username'} /> )} - {isBranchPrefixInputMode && } + {isBranchPrefixInputMode && ( + + )} ) : null, matchesSettingsSearch(searchQuery, { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 95f72406d7cb..7a53012f0ba0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7969,7 +7969,8 @@ "BranchPrefixFeedback": { "6c40c0908f": "Prefix cannot contain spaces or special characters like ~ ^ : ? * [ \\", "64d70b156a": "Branches will be named {{example}}", - "808f9a726e": "No prefix will be applied" + "808f9a726e": "No prefix will be applied", + "c478cf4ec7": "No prefix will be applied because lowercasing this username ends in .lock, which Git rejects." }, "GitPane": { "895d3f70b8": "gh", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3857cbe56f70..0467c064d5c5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7607,7 +7607,8 @@ "BranchPrefixFeedback": { "6c40c0908f": "El prefijo no puede contener espacios ni caracteres especiales como ~ ^ : ? * [ \\", "64d70b156a": "Las ramas se llamarán {{example}}", - "808f9a726e": "No se aplicará ningún prefijo" + "808f9a726e": "No se aplicará ningún prefijo", + "c478cf4ec7": "No se aplicará ningún prefijo porque el nombre de usuario en minúsculas termina en .lock y Git lo rechaza." }, "GitPane": { "895d3f70b8": "gh", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a4b34c154322..e4319a95b651 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7757,7 +7757,8 @@ "BranchPrefixFeedback": { "6c40c0908f": "Le préfixe ne peut pas contenir d'espaces ni de caractères spéciaux comme ~ ^ : ? * [ \\", "64d70b156a": "Les branches seront nommées {{example}}", - "808f9a726e": "Aucun préfixe ne sera appliqué" + "808f9a726e": "Aucun préfixe ne sera appliqué", + "c478cf4ec7": "Aucun préfixe ne sera appliqué, car le nom d'utilisateur en minuscules se termine par .lock, ce que Git refuse." }, "GitPane": { "895d3f70b8": "gh", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 519bc6872a50..cd770e8411f1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7634,7 +7634,8 @@ "BranchPrefixFeedback": { "6c40c0908f": "プレフィックスにスペースや ~ ^ : ? * [ \\ などの特殊文字は使用できません", "64d70b156a": "ブランチ名は {{example}} になります", - "808f9a726e": "プレフィックスは適用されません" + "808f9a726e": "プレフィックスは適用されません", + "c478cf4ec7": "このユーザー名を小文字にすると末尾が .lock になり、Git が拒否するため接頭辞は付きません。" }, "GitPane": { "895d3f70b8": "gh", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fd8d9392801d..58fff212207f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7597,7 +7597,8 @@ "BranchPrefixFeedback": { "6c40c0908f": "접두사에는 공백이나 ~ ^ : ? * [ \\ 같은 특수 문자를 사용할 수 없습니다", "64d70b156a": "브랜치 이름은 {{example}}(으)로 지정됩니다", - "808f9a726e": "접두사가 적용되지 않습니다" + "808f9a726e": "접두사가 적용되지 않습니다", + "c478cf4ec7": "이 사용자 이름을 소문자로 바꾸면 .lock으로 끝나 Git이 거절하므로 접두사를 적용하지 않습니다." }, "GitPane": { "895d3f70b8": "gh", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7346dc7b66e6..03e62130dd78 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7597,7 +7597,8 @@ "BranchPrefixFeedback": { "6c40c0908f": "前缀不能包含空格或 ~ ^ : ? * [ \\ 等特殊字符", "64d70b156a": "分支将命名为 {{example}}", - "808f9a726e": "不会应用前缀" + "808f9a726e": "不会应用前缀", + "c478cf4ec7": "将此用户名转为小写后会以 .lock 结尾,Git 会拒绝,因此不会应用前缀。" }, "GitPane": { "895d3f70b8": "gh", diff --git a/src/shared/branch-prefix.test.ts b/src/shared/branch-prefix.test.ts index f22613367cb3..0b0d6bfb0ba1 100644 --- a/src/shared/branch-prefix.test.ts +++ b/src/shared/branch-prefix.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { assertBranchPrefixValid, getBranchPrefixIssue, + gitUsernamePrefixOmission, normalizeBranchPrefix, selectBranchPrefixInput } from './branch-prefix' @@ -113,6 +114,33 @@ describe('selectBranchPrefixInput', () => { expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, 'jdoe')).toBe('jdoe') }) + it('lowercases a git-username prefix so it cannot collide with an existing ref directory', () => { + expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, 'EurFelux')).toBe('eurfelux') + }) + + it('drops a mixed-case login whose lowercase form git rejects', () => { + expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, 'Alice.LOCK')).toBeNull() + expect(gitUsernamePrefixOmission('Alice.LOCK')).toBe('lowercase-lock') + expect(gitUsernamePrefixOmission('alice.lock')).toBeNull() + expect(gitUsernamePrefixOmission('team x')).toBeNull() + expect(gitUsernamePrefixOmission('EurFelux')).toBeNull() + expect(gitUsernamePrefixOmission(null)).toBeNull() + }) + + it('returns an already-lowercase invalid login so settings can warn', () => { + expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, 'alice.lock')).toBe('alice.lock') + expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, 'team x')).toBe('team x') + }) + + it('leaves a custom prefix in the case the user typed', () => { + expect( + selectBranchPrefixInput( + { branchPrefix: 'custom', branchPrefixCustom: 'EurFelux' }, + 'eurfelux' + ) + ).toBe('EurFelux') + }) + it('returns null for git-username when no username is available', () => { expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, null)).toBeNull() }) diff --git a/src/shared/branch-prefix.ts b/src/shared/branch-prefix.ts index 004fe33e17c1..fc092c194afb 100644 --- a/src/shared/branch-prefix.ts +++ b/src/shared/branch-prefix.ts @@ -6,18 +6,64 @@ export type BranchPrefixSettings = { branchPrefixCustom?: string } +/** A configured login was not used because its lowercase form is a rejected ref. */ +export type GitUsernamePrefixOmission = 'lowercase-lock' + /** - * Pick the raw, un-normalized value the configured strategy contributes, or - * null when no prefix applies. Shared so the main-process branch builder and - * the renderer's live settings feedback agree on which field each strategy uses. + * Why Settings omits a git username. Only a mixed-case login whose lowercase + * form git rejects (`.LOCK` → `.lock`) is omitted. An already-lowercase invalid + * login stays visible so the existing character warning still explains it. + */ +export function gitUsernamePrefixOmission( + gitUsername: string | null +): GitUsernamePrefixOmission | null { + if (gitUsername === null) { + return null + } + const lower = gitUsername.toLowerCase() + if (lower === gitUsername || getBranchPrefixIssue(lower) === null) { + return null + } + if (getBranchPrefixIssue(gitUsername) !== null) { + return null + } + return 'lowercase-lock' +} + +/** + * Pick the value the configured strategy contributes, or null when no prefix + * applies. Shared so the main-process branch builder and the renderer's live + * settings feedback agree on which field each strategy uses. + * + * A git username is lowercased so a mixed-case login cannot collide with an + * existing lowercase ref directory on a case-insensitive filesystem. When that + * lowercase form is a ref git rejects (`Alice.LOCK` becomes `alice.lock`), drop + * the prefix. Keeping the original login creates `refs/heads/Alice.LOCK/`, which + * occupies the `alice.lock` lockfile path on a case-insensitive filesystem. */ export function selectBranchPrefixInput( settings: Omit & { branchPrefix: string }, gitUsername: string | null ): string | null | void { switch (settings.branchPrefix) { - case 'git-username': - return gitUsername + case 'git-username': { + if (gitUsername === null) { + return null + } + const lower = gitUsername.toLowerCase() + if (getBranchPrefixIssue(lower) === null) { + return lower + } + // The original can pass git's case-sensitive check while its lowercase + // form is `*.lock`. Keeping that original still collides on a + // case-insensitive filesystem, so the prefix is omitted. An + // already-lowercase invalid login is returned unchanged so settings can + // show the rejection instead of pretending no username was configured. + if (gitUsernamePrefixOmission(gitUsername) === 'lowercase-lock') { + return null + } + return lower + } case 'custom': return settings.branchPrefixCustom ?? null case 'none':