diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 65c05731..b58d835f 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,6 +843,56 @@ 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, 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()?; + normalize_branch_prefix(json.get("branch-prefix")?.as_str()?) +} + +/// Join the normalized branch prefix onto `branch` with a `/` separator. +fn apply_branch_prefix(prefix: Option<&str>, branch: &str) -> String { + match prefix { + 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 +2644,67 @@ 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_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!( + 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 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] 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 d8b75c9c..77005a7c 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 9d0cd731..8f3015d0 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 915d596e..84bf3b07 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 853a3c03..dbba9c80 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 // =============================================================================