From 712e8c949fe5b873db61a118611654ffe06e1eaf Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 11:01:32 +1000 Subject: [PATCH 1/2] feat(settings): add branch prefix for inferred branch names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Branch prefix" text field to the General settings panel. When a repo is added without an explicit branch name — so the branch name is inferred from the project name — the prefix is prepended with a `/` separator, unless the prefix already ends in `/`. The preference is stored under the `branch-prefix` key in preferences.json and read directly by the backend (same pattern as `recent-agents`) in all three inference paths: add_project_repo_impl, the create_project Tauri command, and the web server's create_project handler. Explicit branch names and the workspace-name fallback are unaffected. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/branches.rs | 57 +++++++++++++++++++ apps/staged/src-tauri/src/lib.rs | 2 +- apps/staged/src-tauri/src/project_commands.rs | 2 +- apps/staged/src-tauri/src/web_server.rs | 2 +- .../settings/GeneralSettingsPanel.svelte | 31 ++++++++++ .../features/settings/preferences.svelte.ts | 28 +++++++++ 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 65c057318..b5725efc8 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -837,6 +837,42 @@ pub(crate) fn infer_branch_name(project_name: &str) -> String { } } +/// Read the user's configured branch prefix (General settings → Branch +/// prefix) from the preferences store. Returns `None` when unset, blank, or +/// consisting only of slashes. +fn read_branch_prefix() -> Option { + let path = crate::preferences_store_path_buf()?; + let contents = std::fs::read_to_string(&path).ok()?; + let json = serde_json::from_str::(&contents).ok()?; + let prefix = json.get("branch-prefix")?.as_str()?.trim(); + if prefix.trim_matches('/').is_empty() { + return None; + } + Some(prefix.to_string()) +} + +/// Join the configured branch prefix onto `branch` with a `/` separator, +/// unless the prefix already ends in `/`. +fn apply_branch_prefix(prefix: Option<&str>, branch: &str) -> String { + match prefix { + Some(p) if p.ends_with('/') => format!("{p}{branch}"), + Some(p) => format!("{p}/{branch}"), + None => branch.to_string(), + } +} + +/// Infer a branch name from the project name, applying the user's configured +/// branch prefix. Used whenever a repo is added without an explicit branch +/// name. The [`resolve_project_workspace_name`] fallback stays on the +/// unprefixed [`infer_branch_name`] so existing workspace identities are +/// unaffected by the setting. +pub(crate) fn infer_prefixed_branch_name(project_name: &str) -> String { + apply_branch_prefix( + read_branch_prefix().as_deref(), + &infer_branch_name(project_name), + ) +} + pub(crate) fn infer_workspace_name(branch_name: &str) -> String { const WORKSPACE_NAME_MAX_LENGTH: usize = 32; let safe = branch_name @@ -2588,6 +2624,27 @@ mod tests { use super::*; use crate::test_utils::TempGitRepo; + #[test] + fn apply_branch_prefix_joins_with_slash() { + assert_eq!( + apply_branch_prefix(Some("mtoohey"), "my-project"), + "mtoohey/my-project" + ); + } + + #[test] + fn apply_branch_prefix_skips_separator_when_prefix_ends_in_slash() { + assert_eq!( + apply_branch_prefix(Some("mtoohey/"), "my-project"), + "mtoohey/my-project" + ); + } + + #[test] + fn apply_branch_prefix_without_prefix_returns_branch_unchanged() { + assert_eq!(apply_branch_prefix(None, "my-project"), "my-project"); + } + #[test] fn local_base_ref_for_worktree_accepts_existing_origin_ref() { let repo = TempGitRepo::new(); diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d8b75c9c7..77005a7cd 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -423,7 +423,7 @@ fn create_project( .map(str::trim) .filter(|s| !s.is_empty()) .map(ToOwned::to_owned) - .unwrap_or_else(|| branches::infer_branch_name(trimmed)); + .unwrap_or_else(|| branches::infer_prefixed_branch_name(trimmed)); let mut project = store::Project::named(trimmed); project.location = project_location; if let Some(repo) = github_repo.clone() { diff --git a/apps/staged/src-tauri/src/project_commands.rs b/apps/staged/src-tauri/src/project_commands.rs index 9d0cd7311..8f3015d0f 100644 --- a/apps/staged/src-tauri/src/project_commands.rs +++ b/apps/staged/src-tauri/src/project_commands.rs @@ -31,7 +31,7 @@ pub(crate) async fn add_project_repo_impl( .map(str::trim) .filter(|s| !s.is_empty()) .map(ToOwned::to_owned) - .unwrap_or_else(|| branches::infer_branch_name(&project.name)); + .unwrap_or_else(|| branches::infer_prefixed_branch_name(&project.name)); // If this github_repo is already attached to the project (i.e. being added // again with a different subpath), make the branch name unique by appending // a subpath-derived suffix. Without this, `git worktree add -b ` diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 915d596e7..84bf3b073 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -498,7 +498,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + const prefix = preferences.branchPrefix.trim(); + if (!prefix) return 'my-project'; + return prefix.endsWith('/') ? `${prefix}my-project` : `${prefix}/my-project`; + });
@@ -179,6 +187,29 @@ {/if}

+ +
+ + setBranchPrefix(e.currentTarget.value)} + /> +

+ + {#if preferences.branchPrefix.trim()} + Branch names generated from project names will look like {branchPrefixExample}. + {:else} + Prepended (with a / separator) to branch names generated from project names when a repo is + added without choosing a branch. + {/if} +

+
diff --git a/apps/staged/src/lib/features/settings/preferences.svelte.ts b/apps/staged/src/lib/features/settings/preferences.svelte.ts index 853a3c031..dbba9c804 100644 --- a/apps/staged/src/lib/features/settings/preferences.svelte.ts +++ b/apps/staged/src/lib/features/settings/preferences.svelte.ts @@ -42,6 +42,8 @@ const DIFF_THEME_STORE_KEY = 'diff-theme'; const APP_MODE_STORE_KEY = 'app-mode'; const RECENT_AGENTS_STORE_KEY = 'recent-agents'; const AUTO_REVIEW_STORE_KEY = 'auto-start-code-reviews'; +/** Prefix applied to branch names inferred from project names (backend-read). */ +const BRANCH_PREFIX_STORE_KEY = 'branch-prefix'; /** Maximum number of recent agents to remember. */ const RECENT_AGENTS_MAX = 10; @@ -127,6 +129,12 @@ export const preferences = $state({ recentAgents: [] as string[], /** Whether auto code reviews are triggered after commits */ autoReviewMode: 'after-changes' as AutoReviewMode, + /** + * Prefix for branch names generated from project names (e.g. when adding a + * repo without picking a branch). Joined with a `/` unless it already ends + * in one. Empty means no prefix. Read by the backend from the store file. + */ + branchPrefix: '', /** Whether all preferences have been loaded from storage */ loaded: false, }); @@ -272,6 +280,12 @@ export async function initPreferences(): Promise { } else { preferences.autoReviewMode = (await loadSqAvailabilityForDefault()) ? 'after-changes' : 'never'; } + + // Load branch prefix + const savedBranchPrefix = await getStoreValue(BRANCH_PREFIX_STORE_KEY); + if (typeof savedBranchPrefix === 'string') { + preferences.branchPrefix = savedBranchPrefix; + } } // ============================================================================= @@ -318,6 +332,20 @@ export function setAutoReviewMode(mode: AutoReviewMode): void { setStoreValue(AUTO_REVIEW_STORE_KEY, mode); } +// ============================================================================= +// Branch Prefix Actions +// ============================================================================= + +/** + * Set the branch prefix applied to branch names generated from project names. + * Stored trimmed; the backend reads it directly from the store file when + * inferring branch names. + */ +export function setBranchPrefix(prefix: string): void { + preferences.branchPrefix = prefix; + setStoreValue(BRANCH_PREFIX_STORE_KEY, prefix.trim()); +} + // ============================================================================= // Size Actions // ============================================================================= From 08b6e6fe5f9a5e6fe37dd0130916018c4da1fbf5 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 12:02:31 +1000 Subject: [PATCH 2/2] fix(settings): sanitize branch prefix before building inferred refs The branch prefix from preferences was spliced raw into inferred branch names, so values like "Matt Toohey", "/mtoohey", "foo//", or ones containing ".."/"~"/".lock" flowed into `git worktree add -b` and failed later as opaque git errors. Normalize the prefix at usage time with the same rules infer_branch_name applies to project names, applied per /-separated segment so multi-level prefixes like "team/mtoohey" keep their hierarchy. Empty segments are dropped, which also removes the trailing-slash special case in apply_branch_prefix: a normalized prefix never starts or ends with "/", so it always joins with a single separator. Prefixes with nothing valid left after normalization are treated as unset. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/branches.rs | 98 +++++++++++++++++++++------ 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index b5725efc8..b58d835f6 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -818,9 +818,11 @@ pub(crate) fn cleanup_branch_resources_best_effort(store: &Arc, branch: & } } -pub(crate) fn infer_branch_name(project_name: &str) -> String { - let branch = project_name - .to_lowercase() +/// Reduce `name` to a valid single branch-name component: lowercased, +/// separators collapsed to `-`, everything else dropped. May return an empty +/// string. +fn normalize_branch_component(name: &str) -> String { + name.to_lowercase() .replace([' ', '_'], "-") .chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '/' || *c == '.') @@ -829,7 +831,11 @@ pub(crate) fn infer_branch_name(project_name: &str) -> String { .split('-') .filter(|s| !s.is_empty()) .collect::>() - .join("-"); + .join("-") +} + +pub(crate) fn infer_branch_name(project_name: &str) -> String { + let branch = normalize_branch_component(project_name); if branch.is_empty() { "feature".to_string() } else { @@ -837,25 +843,39 @@ pub(crate) fn infer_branch_name(project_name: &str) -> String { } } +/// Normalize a configured branch prefix with the same rules +/// [`infer_branch_name`] applies to project names, per `/`-separated segment +/// so multi-level prefixes like `team/mtoohey` keep their hierarchy. Empty +/// segments are dropped, so leading, trailing, and doubled slashes cannot +/// produce an invalid ref. Returns `None` when nothing valid remains. +fn normalize_branch_prefix(prefix: &str) -> Option { + let normalized = prefix + .split('/') + .map(normalize_branch_component) + .filter(|s| !s.is_empty()) + .collect::>() + .join("/"); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + /// Read the user's configured branch prefix (General settings → Branch -/// prefix) from the preferences store. Returns `None` when unset, blank, or -/// consisting only of slashes. +/// prefix) from the preferences store, normalized via +/// [`normalize_branch_prefix`]. Returns `None` when unset or when nothing +/// valid remains after normalization. fn read_branch_prefix() -> Option { let path = crate::preferences_store_path_buf()?; let contents = std::fs::read_to_string(&path).ok()?; let json = serde_json::from_str::(&contents).ok()?; - let prefix = json.get("branch-prefix")?.as_str()?.trim(); - if prefix.trim_matches('/').is_empty() { - return None; - } - Some(prefix.to_string()) + normalize_branch_prefix(json.get("branch-prefix")?.as_str()?) } -/// Join the configured branch prefix onto `branch` with a `/` separator, -/// unless the prefix already ends in `/`. +/// Join the normalized branch prefix onto `branch` with a `/` separator. fn apply_branch_prefix(prefix: Option<&str>, branch: &str) -> String { match prefix { - Some(p) if p.ends_with('/') => format!("{p}{branch}"), Some(p) => format!("{p}/{branch}"), None => branch.to_string(), } @@ -2633,16 +2653,56 @@ mod tests { } #[test] - fn apply_branch_prefix_skips_separator_when_prefix_ends_in_slash() { + fn apply_branch_prefix_without_prefix_returns_branch_unchanged() { + assert_eq!(apply_branch_prefix(None, "my-project"), "my-project"); + } + + #[test] + fn normalize_branch_prefix_sanitizes_like_project_names() { assert_eq!( - apply_branch_prefix(Some("mtoohey/"), "my-project"), - "mtoohey/my-project" + normalize_branch_prefix("Matt Toohey"), + Some("matt-toohey".to_string()) + ); + assert_eq!( + normalize_branch_prefix("branch.lock"), + Some("branch-lock".to_string()) + ); + assert_eq!( + normalize_branch_prefix("~fix..up"), + Some("fix-up".to_string()) ); } #[test] - fn apply_branch_prefix_without_prefix_returns_branch_unchanged() { - assert_eq!(apply_branch_prefix(None, "my-project"), "my-project"); + fn normalize_branch_prefix_preserves_multi_level_prefixes() { + assert_eq!( + normalize_branch_prefix("team/mtoohey"), + Some("team/mtoohey".to_string()) + ); + } + + #[test] + fn normalize_branch_prefix_drops_empty_segments() { + assert_eq!( + normalize_branch_prefix("mtoohey/"), + Some("mtoohey".to_string()) + ); + assert_eq!( + normalize_branch_prefix("/mtoohey"), + Some("mtoohey".to_string()) + ); + assert_eq!( + normalize_branch_prefix("foo//bar"), + Some("foo/bar".to_string()) + ); + } + + #[test] + fn normalize_branch_prefix_rejects_prefixes_with_nothing_valid() { + assert_eq!(normalize_branch_prefix(""), None); + assert_eq!(normalize_branch_prefix(" "), None); + assert_eq!(normalize_branch_prefix("///"), None); + assert_eq!(normalize_branch_prefix("~/.."), None); } #[test]