Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 121 additions & 4 deletions apps/staged/src-tauri/src/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,9 +818,11 @@ pub(crate) fn cleanup_branch_resources_best_effort(store: &Arc<Store>, 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 == '.')
Expand All @@ -829,14 +831,68 @@ pub(crate) fn infer_branch_name(project_name: &str) -> String {
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.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 {
branch
}
}

/// 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<String> {
let normalized = prefix
.split('/')
.map(normalize_branch_component)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.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<String> {
let path = crate::preferences_store_path_buf()?;
let contents = std::fs::read_to_string(&path).ok()?;
let json = serde_json::from_str::<serde_json::Value>(&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
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion apps/staged/src-tauri/src/project_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`
Expand Down
2 changes: 1 addition & 1 deletion apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| crate::branches::infer_branch_name(trimmed));
.unwrap_or_else(|| crate::branches::infer_prefixed_branch_name(trimmed));

let mut project = crate::store::Project::named(trimmed);
project.location = project_location;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
import * as Select from '$lib/components/ui/select';
import * as Popover from '$lib/components/ui/popover';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import {
preferences,
getAvailableSyntaxThemes,
selectDiffTheme,
setMode,
setAutoReviewMode,
setBranchPrefix,
loadAllThemePreviewColors,
isLightTheme,
type AppMode,
Expand Down Expand Up @@ -60,6 +62,12 @@
selectDiffTheme(name);
dropdownOpen = false;
}

const branchPrefixExample = $derived.by(() => {
const prefix = preferences.branchPrefix.trim();
if (!prefix) return 'my-project';
return prefix.endsWith('/') ? `${prefix}my-project` : `${prefix}/my-project`;
});
</script>

<div class="general-settings-panel">
Expand Down Expand Up @@ -179,6 +187,29 @@
{/if}
</p>
</div>

<div class="field">
<Label for="branch-prefix-input" class="text-foreground text-sm font-semibold"
>Branch prefix</Label
>
<Input
id="branch-prefix-input"
type="text"
placeholder="e.g. mtoohey"
class="max-w-[320px]"
value={preferences.branchPrefix}
oninput={(e) => setBranchPrefix(e.currentTarget.value)}
/>
<p class="field-description">
<Info size={12} />
{#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}
</p>
</div>
</div>
</div>

Expand Down
28 changes: 28 additions & 0 deletions apps/staged/src/lib/features/settings/preferences.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -272,6 +280,12 @@ export async function initPreferences(): Promise<void> {
} else {
preferences.autoReviewMode = (await loadSqAvailabilityForDefault()) ? 'after-changes' : 'never';
}

// Load branch prefix
const savedBranchPrefix = await getStoreValue<string>(BRANCH_PREFIX_STORE_KEY);
if (typeof savedBranchPrefix === 'string') {
preferences.branchPrefix = savedBranchPrefix;
}
}

// =============================================================================
Expand Down Expand Up @@ -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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist branch prefix for web clients

When the app is running via the web server (isTauri === false), initPersistentStore leaves backend null, so this new setter only changes the local Svelte state and setStoreValue returns without writing preferences.json; however the web create_project path now calls infer_prefixed_branch_name, which reads that server-side file. In that environment, setting Branch prefix in General settings and then adding/creating a repo without an explicit branch still produces the unprefixed branch name, so the setting appears to work in the UI but has no effect.

Useful? React with 👍 / 👎.

}

// =============================================================================
// Size Actions
// =============================================================================
Expand Down