diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 715546eb..6797e3ff 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4779,7 +4779,7 @@ fn create_session_inner( name = project_basename(&repo); } let base = sanitize_worktree_name(&name); - let (_safe_name, path) = create_unique_worktree(&repo, &base)?; + let (_safe_name, path) = create_unique_project_worktree(&repo, &base)?; path } else { cwd_path.unwrap_or(selected_path) @@ -6747,7 +6747,17 @@ pub fn update_project_settings( repo_path: String, settings: ProjectSettings, ) -> AppResult { - project_settings::update(&PathBuf::from(repo_path), settings) + let repo_path = PathBuf::from(repo_path); + if let Some(branch) = settings + .worktrees + .base_branch + .as_deref() + .map(str::trim) + .filter(|branch| !branch.is_empty()) + { + worktree::validate_worktree_base_branch(&repo_path, branch)?; + } + project_settings::update(&repo_path, settings) } fn validate_new_project_name(name: &str, ignore_safe_name: bool) -> AppResult<&str> { @@ -7481,7 +7491,7 @@ pub fn prepare_chat_session_worktree( return Ok(enrich_session(session)); } let base = new_chat_worktree_base_name(&session.repo_path); - let (_safe_name, path) = create_unique_worktree(&session.repo_path, &base)?; + let (_safe_name, path) = create_unique_project_worktree(&session.repo_path, &base)?; let updated = state.sessions.update_worktree_path(&session.id, path)?; persist(&state); Ok(enrich_session(updated)) @@ -7844,6 +7854,12 @@ pub fn list_project_worktrees(repo_path: String) -> AppResult AppResult> { + let path = PathBuf::from(repo_path); + crate::worktree::list_branch_infos(&path) +} + #[tauri::command] pub async fn remove_worktree( state: State<'_, AppState>, @@ -10745,9 +10761,30 @@ pub async fn generate_pr_commit_message( pull_requests::generate_pr_commit_message(&PathBuf::from(repo_path), number, method, ai, prompt) } +#[cfg(test)] pub(crate) fn create_unique_worktree( repo: &std::path::Path, base: &str, +) -> AppResult<(String, PathBuf)> { + create_unique_worktree_from_base_branch(repo, base, None) +} + +pub(crate) fn create_unique_project_worktree( + repo: &std::path::Path, + base: &str, +) -> AppResult<(String, PathBuf)> { + let settings = project_settings::get(repo)?; + create_unique_worktree_from_base_branch( + repo, + base, + settings.settings.worktrees.base_branch.as_deref(), + ) +} + +fn create_unique_worktree_from_base_branch( + repo: &std::path::Path, + base: &str, + base_branch: Option<&str>, ) -> AppResult<(String, PathBuf)> { let root = worktree::worktree_root(repo); let mut candidate = base.to_string(); @@ -10755,7 +10792,7 @@ pub(crate) fn create_unique_worktree( loop { let target = root.join(&candidate); if !target.exists() { - match worktree::create_worktree(repo, &candidate) { + match worktree::create_worktree_from_base_branch(repo, &candidate, base_branch) { Ok(path) => return Ok((candidate, path)), Err(AppError::InvalidPath(_)) => {} // libgit2 auto-creates a branch named after the worktree when diff --git a/src-tauri/src/ipc/server.rs b/src-tauri/src/ipc/server.rs index b9510005..722121d6 100644 --- a/src-tauri/src/ipc/server.rs +++ b/src-tauri/src/ipc/server.rs @@ -43,7 +43,8 @@ use tauri::{AppHandle, Emitter, Runtime}; use uuid::Uuid; use crate::commands::{ - create_unique_worktree, sanitize_worktree_name, session_removal_cascade, terminate_session_pty, + create_unique_project_worktree, sanitize_worktree_name, session_removal_cascade, + terminate_session_pty, }; use crate::ipc::workspaces::{ListWorkspacesRequestPayload, LIST_WORKSPACES_REQUEST_EVENT}; use crate::persistence; @@ -737,7 +738,7 @@ fn handle_new_session( }; let worktree_path = if isolated { let base = sanitize_worktree_name(&name); - match create_unique_worktree(&repo, &base) { + match create_unique_project_worktree(&repo, &base) { Ok((_safe, path)) => path, Err(err) => { return Response::Error { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a7b7313..36b2808e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -775,6 +775,7 @@ pub fn run() { commands::prepare_chat_session_worktree, commands::git_worktrees, commands::list_project_worktrees, + commands::list_project_branches, commands::remove_worktree, commands::restore_removed_worktree, commands::discard_removed_worktree, diff --git a/src-tauri/src/project_settings.rs b/src-tauri/src/project_settings.rs index c16f5155..fa93cb5e 100644 --- a/src-tauri/src/project_settings.rs +++ b/src-tauri/src/project_settings.rs @@ -10,6 +10,7 @@ use crate::{git_ops, persistence}; const PROJECT_SETTINGS_FILE: &str = "project_settings.json"; const PROJECT_SETTINGS_TMP_FILE: &str = "project_settings.json.tmp"; pub const PR_GENERATION_PROMPT_MAX_CHARS: usize = 2_000; +pub const WORKTREE_BASE_BRANCH_MAX_CHARS: usize = 255; pub const STANDARD_PR_GENERATION_PROMPT: &str = "Use a standard GitHub-style pull request merge message. - First line: Conventional Commit subject when the type is clear, e.g. feat(scope): concise summary. Keep it imperative/present tense and <=72 chars. - Body: 1-2 concise paragraphs explaining why the change matters, user-visible impact, and key implementation notes when useful. @@ -21,6 +22,8 @@ pub struct ProjectSettings { pub remember_after_close: bool, #[serde(default)] pub pull_requests: ProjectPullRequestSettings, + #[serde(default)] + pub worktrees: ProjectWorktreeSettings, } impl Default for ProjectSettings { @@ -28,6 +31,7 @@ impl Default for ProjectSettings { Self { remember_after_close: true, pull_requests: ProjectPullRequestSettings::default(), + worktrees: ProjectWorktreeSettings::default(), } } } @@ -46,6 +50,12 @@ impl Default for ProjectPullRequestSettings { } } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectWorktreeSettings { + #[serde(default)] + pub base_branch: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProjectSettingsRecord { pub key: String, @@ -140,6 +150,19 @@ fn normalize_settings(mut settings: ProjectSettings) -> ProjectSettings { ) } }); + settings.worktrees.base_branch = settings.worktrees.base_branch.and_then(|branch| { + let trimmed = branch.trim(); + if trimmed.is_empty() { + None + } else { + Some( + trimmed + .chars() + .take(WORKTREE_BASE_BRANCH_MAX_CHARS) + .collect(), + ) + } + }); settings } @@ -171,6 +194,9 @@ mod tests { pull_requests: ProjectPullRequestSettings { generation_prompt: Some("Write concise Korean PR messages.".to_string()), }, + worktrees: ProjectWorktreeSettings { + base_branch: Some("develop".to_string()), + }, }; let saved = update(&repo, settings).unwrap(); @@ -182,6 +208,10 @@ mod tests { loaded.settings.pull_requests.generation_prompt.as_deref(), Some("Write concise Korean PR messages.") ); + assert_eq!( + loaded.settings.worktrees.base_branch.as_deref(), + Some("develop") + ); }); } @@ -201,6 +231,17 @@ mod tests { }); } + #[test] + fn settings_without_worktree_fields_use_automatic_base_branch() { + let settings: ProjectSettings = serde_json::from_value(serde_json::json!({ + "remember_after_close": true, + "pull_requests": { "generation_prompt": null } + })) + .unwrap(); + + assert_eq!(settings.worktrees, ProjectWorktreeSettings::default()); + } + #[test] fn blank_prompt_normalizes_to_none() { with_data_dir(|_| { @@ -213,6 +254,9 @@ mod tests { pull_requests: ProjectPullRequestSettings { generation_prompt: Some(" ".to_string()), }, + worktrees: ProjectWorktreeSettings { + base_branch: Some(" ".to_string()), + }, }, ) .unwrap(); @@ -221,6 +265,7 @@ mod tests { get(&repo).unwrap().settings.pull_requests.generation_prompt, None ); + assert_eq!(get(&repo).unwrap().settings.worktrees.base_branch, None); }); } @@ -236,6 +281,7 @@ mod tests { ProjectSettings { remember_after_close: false, pull_requests: ProjectPullRequestSettings::default(), + worktrees: ProjectWorktreeSettings::default(), }, ) .unwrap(); diff --git a/src-tauri/src/worktree.rs b/src-tauri/src/worktree.rs index 6d54c976..71be88c5 100644 --- a/src-tauri/src/worktree.rs +++ b/src-tauri/src/worktree.rs @@ -29,6 +29,12 @@ pub struct ProjectWorktreeInfo { pub modified_ms: Option, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ProjectBranchInfo { + pub name: String, + pub is_remote: bool, +} + pub fn ensure_repo(path: &Path) -> AppResult { // `discover` walks up from `path` to find the nearest `.git`, so callers // can pass any subdirectory (e.g. a session's PTY cwd that drifted into @@ -211,7 +217,16 @@ fn validate_same_file(_path: &Path, _before: &Metadata, _opened: &Metadata) -> A Ok(()) } +#[cfg(test)] pub fn create_worktree(repo_path: &Path, name: &str) -> AppResult { + create_worktree_from_base_branch(repo_path, name, None) +} + +pub fn create_worktree_from_base_branch( + repo_path: &Path, + name: &str, + base_branch: Option<&str>, +) -> AppResult { let repo = ensure_repo(repo_path)?; ensure_git_excluded(&repo).ok(); let root = checked_worktree_root(repo_path, true)?; @@ -224,7 +239,7 @@ pub fn create_worktree(repo_path: &Path, name: &str) -> AppResult { ))); } - let base = worktree_base_commit(&repo)?; + let base = worktree_base_commit(&repo, base_branch)?; repo.branch(name, &base, false)?; let branch_ref_name = format!("refs/heads/{name}"); let branch_ref = repo.find_reference(&branch_ref_name)?; @@ -239,7 +254,19 @@ pub fn create_worktree(repo_path: &Path, name: &str) -> AppResult { Ok(target) } -fn worktree_base_commit(repo: &Repository) -> AppResult> { +pub fn validate_worktree_base_branch(repo_path: &Path, branch: &str) -> AppResult<()> { + let repo = ensure_repo(repo_path)?; + configured_worktree_base_commit(&repo, branch).map(|_| ()) +} + +fn worktree_base_commit<'repo>( + repo: &'repo Repository, + configured_branch: Option<&str>, +) -> AppResult> { + if let Some(branch) = configured_branch { + return configured_worktree_base_commit(repo, branch); + } + // Acorn-created worktrees start from the project's stable default branch, // not whichever feature branch the project root is currently using. for name in [ @@ -258,6 +285,37 @@ fn worktree_base_commit(repo: &Repository) -> AppResult> { Ok(repo.head()?.peel_to_commit()?) } +fn configured_worktree_base_commit<'repo>( + repo: &'repo Repository, + branch: &str, +) -> AppResult> { + let branch = branch.trim(); + let candidates = if branch.starts_with("refs/heads/") || branch.starts_with("refs/remotes/") { + vec![branch.to_string()] + } else if branch.starts_with("refs/") { + Vec::new() + } else { + vec![ + format!("refs/heads/{branch}"), + format!("refs/remotes/{branch}"), + format!("refs/remotes/origin/{branch}"), + ] + }; + + for name in candidates { + if let Ok(commit) = repo + .find_reference(&name) + .and_then(|reference| reference.peel_to_commit()) + { + return Ok(commit); + } + } + + Err(AppError::Other(format!( + "configured worktree base branch was not found: {branch}" + ))) +} + /// Returns absolute on-disk paths of linked worktrees. Used by the /// post-PTY-exit "did claude just create a worktree?" detector — names alone /// aren't enough because we need to point a session at the new worktree's @@ -293,6 +351,35 @@ pub fn list_worktree_infos(repo_path: &Path) -> AppResult AppResult> { + let repo = ensure_repo(repo_path)?; + let mut infos = Vec::new(); + for entry in repo.branches(None)? { + let (branch, kind) = entry?; + if branch.get().symbolic_target()?.is_some() { + continue; + } + let Some(name) = branch.name()? else { + continue; + }; + infos.push(ProjectBranchInfo { + name: name.to_string(), + is_remote: kind == BranchType::Remote, + }); + } + infos.sort_by(|a, b| { + a.is_remote + .cmp(&b.is_remote) + .then_with(|| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + }) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(infos) +} + fn project_worktree_info_from_path(path: PathBuf) -> ProjectWorktreeInfo { let name = path .file_name() @@ -839,6 +926,72 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn create_worktree_starts_from_configured_base_branch() { + let root = unique_temp_dir("base-configured"); + let repo = init_repo_with_tracked_file(&root); + let sig = git2::Signature::now("acorn-test", "test@acorn").expect("sig"); + let initial = repo + .head() + .and_then(|head| head.peel_to_commit()) + .expect("initial commit"); + repo.branch("main", &initial, false) + .expect("create main branch"); + repo.branch("develop", &initial, false) + .expect("create develop branch"); + drop(initial); + + checkout_branch(&repo, "develop"); + std::fs::write(root.join("tracked.txt"), "develop").expect("write develop contents"); + let tree_id = { + let mut idx = repo.index().expect("index"); + idx.add_path(Path::new("tracked.txt")) + .expect("add develop file"); + idx.write_tree().expect("write develop tree") + }; + let tree = repo.find_tree(tree_id).expect("develop tree"); + let parent = repo + .head() + .and_then(|head| head.peel_to_commit()) + .expect("develop parent"); + let develop_oid = repo + .commit(Some("HEAD"), &sig, &sig, "develop", &tree, &[&parent]) + .expect("develop commit"); + drop(parent); + drop(tree); + checkout_branch(&repo, "main"); + drop(repo); + + let worktree_path = create_worktree_from_base_branch(&root, "worker", Some("develop")) + .expect("create worktree"); + let worktree_repo = Repository::open(&worktree_path).expect("open worktree repo"); + let head = worktree_repo.head().expect("worktree head"); + + assert_eq!(head.shorthand().expect("branch shorthand"), "worker"); + assert_eq!(head.target(), Some(develop_oid)); + assert_eq!( + std::fs::read_to_string(worktree_path.join("tracked.txt")).unwrap(), + "develop" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn configured_worktree_base_branch_must_exist() { + let root = unique_temp_dir("base-missing"); + let repo = init_repo_with_tracked_file(&root); + drop(repo); + + let error = validate_worktree_base_branch(&root, "missing") + .expect_err("missing base branch must be rejected"); + + assert!(error + .to_string() + .contains("configured worktree base branch was not found: missing")); + std::fs::remove_dir_all(&root).ok(); + } + #[cfg(unix)] #[test] fn create_worktree_rejects_symlinked_acorn_directory() { @@ -1162,6 +1315,53 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn list_branch_infos_includes_local_and_remote_tracking_branches() { + let root = unique_temp_dir("branch-info"); + let repo = init_repo_with_tracked_file(&root); + let initial = repo + .head() + .and_then(|head| head.peel_to_commit()) + .expect("initial commit"); + repo.branch("develop", &initial, false) + .expect("create local branch"); + repo.reference( + "refs/remotes/origin/release", + initial.id(), + false, + "create remote-tracking branch", + ) + .expect("create remote-tracking branch"); + repo.reference_symbolic( + "refs/remotes/origin/HEAD", + "refs/remotes/origin/release", + false, + "set remote HEAD", + ) + .expect("create symbolic remote HEAD"); + drop(initial); + drop(repo); + + let infos = list_branch_infos(&root).expect("list branches"); + + assert!(infos.contains(&ProjectBranchInfo { + name: "develop".to_string(), + is_remote: false, + })); + assert!(infos.contains(&ProjectBranchInfo { + name: "origin/release".to_string(), + is_remote: true, + })); + assert!(!infos.iter().any(|branch| branch.name == "origin/HEAD")); + let first_remote = infos + .iter() + .position(|branch| branch.is_remote) + .expect("remote branch"); + assert!(infos[..first_remote].iter().all(|branch| !branch.is_remote)); + + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn remove_worktree_at_path_allows_already_missing_path() { let root = unique_temp_dir("remove-missing"); diff --git a/src/components/ProjectSettingsModal.test.tsx b/src/components/ProjectSettingsModal.test.tsx index dfad5a9f..b820323f 100644 --- a/src/components/ProjectSettingsModal.test.tsx +++ b/src/components/ProjectSettingsModal.test.tsx @@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Project, + ProjectBranch, ProjectSettingsRecord, ProjectWorktree, Session, @@ -15,6 +16,9 @@ vi.mock("../lib/api", () => ({ listProjectWorktrees: vi.fn< (repoPath: string) => Promise >(), + listProjectBranches: vi.fn< + (repoPath: string) => Promise + >(), listProjects: vi.fn<() => Promise>(), listSessions: vi.fn<() => Promise>(), removeWorktree: vi.fn< @@ -96,11 +100,13 @@ describe("ProjectSettingsModal", () => { }); mockApi.getProjectSettings.mockReset(); mockApi.listProjectWorktrees.mockReset(); + mockApi.listProjectBranches.mockReset(); mockApi.listProjects.mockReset(); mockApi.listSessions.mockReset(); mockApi.removeWorktree.mockReset(); mockApi.updateProjectSettings.mockReset(); mockApi.listProjectWorktrees.mockResolvedValue([]); + mockApi.listProjectBranches.mockResolvedValue([]); mockApi.listProjects.mockResolvedValue([]); mockApi.listSessions.mockResolvedValue([]); mockApi.removeWorktree.mockResolvedValue(null); @@ -119,6 +125,7 @@ describe("ProjectSettingsModal", () => { pull_requests: { generation_prompt: "Use concise release-note style.", }, + worktrees: { base_branch: null }, }, }); mockApi.updateProjectSettings.mockImplementation( @@ -185,6 +192,7 @@ describe("ProjectSettingsModal", () => { pull_requests: { generation_prompt: "Write Korean release notes.", }, + worktrees: { base_branch: null }, }); expect(onClose).toHaveBeenCalled(); }); @@ -216,6 +224,7 @@ describe("ProjectSettingsModal", () => { pull_requests: { generation_prompt: "Use concise release-note style.", }, + worktrees: { base_branch: null }, }, }); mockApi.listProjectWorktrees @@ -304,6 +313,7 @@ describe("ProjectSettingsModal", () => { pull_requests: { generation_prompt: "Use concise release-note style.", }, + worktrees: { base_branch: null }, }, }); mockApi.listProjectWorktrees @@ -401,6 +411,7 @@ describe("ProjectSettingsModal", () => { pull_requests: { generation_prompt: "Use concise release-note style.", }, + worktrees: { base_branch: null }, }, }); mockApi.listProjectWorktrees.mockResolvedValue([ @@ -487,6 +498,7 @@ describe("ProjectSettingsModal", () => { pull_requests: { generation_prompt: "Use concise release-note style.", }, + worktrees: { base_branch: null }, }, }); mockApi.listProjectWorktrees.mockResolvedValue([ @@ -572,6 +584,7 @@ describe("ProjectSettingsModal", () => { settings: { remember_after_close: false, pull_requests: { generation_prompt: null }, + worktrees: { base_branch: null }, }, }); useAppStore.setState({ diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index e752d74d..80f97923 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -9,7 +9,7 @@ import { Trash2, X, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { api } from "../lib/api"; import { useDialogShortcuts } from "../lib/dialog"; import type { TranslationKey, Translator } from "../lib/i18n"; @@ -19,7 +19,12 @@ import { sessionsUsingProjectWorktree, sessionsUsingWorktreePath, } from "../lib/sessionWorktree"; -import type { ProjectSettings, ProjectWorktree, Session } from "../lib/types"; +import type { + ProjectBranch, + ProjectSettings, + ProjectWorktree, + Session, +} from "../lib/types"; import { useTranslation } from "../lib/useTranslation"; import { useAppStore } from "../store"; import { ContextMenu } from "./ContextMenu"; @@ -33,10 +38,17 @@ import { ModalHeader, Notice, SegmentedControl, + Select, + type SelectItem, + type SelectOptionGroup, } from "./ui"; const PROMPT_MAX_CHARS = 2_000; const NAME_MAX_CHARS = 120; +const BRANCH_MAX_CHARS = 255; +const AUTOMATIC_BASE_BRANCH_VALUE = "automatic"; +const CUSTOM_BASE_BRANCH_VALUE = "custom"; +const BRANCH_VALUE_PREFIX = "branch:"; type DialogTranslationKey = Extract; export type ProjectSettingsTab = @@ -100,9 +112,18 @@ function defaultProjectSettings(): ProjectSettings { pull_requests: { generation_prompt: STANDARD_PR_GENERATION_PROMPT, }, + worktrees: { + base_branch: null, + }, }; } +function projectBranchReference(branch: ProjectBranch): string { + return branch.is_remote + ? `refs/remotes/${branch.name}` + : `refs/heads/${branch.name}`; +} + function promptCount(template: string, count: number): string { return template .replace("{count}", String(count)) @@ -185,8 +206,10 @@ export function ProjectSettingsModal({ ); const [identity, setIdentity] = useState(null); const [worktrees, setWorktrees] = useState([]); + const [branches, setBranches] = useState([]); const [loading, setLoading] = useState(false); const [worktreesLoading, setWorktreesLoading] = useState(false); + const [branchesLoading, setBranchesLoading] = useState(false); const [saving, setSaving] = useState(false); const [removingPath, setRemovingPath] = useState(null); const [confirmRemove, setConfirmRemove] = @@ -196,6 +219,7 @@ export function ProjectSettingsModal({ kind: "load" | "remove"; message: string; } | null>(null); + const [branchError, setBranchError] = useState(null); const confirmRemoveSessions = confirmRemove ? sessionsUsingProjectWorktree( @@ -250,23 +274,29 @@ export function ProjectSettingsModal({ setSettings(defaultProjectSettings()); setIdentity(null); setWorktrees([]); + setBranches([]); setLoading(false); setWorktreesLoading(false); + setBranchesLoading(false); setSaving(false); setRemovingPath(null); setConfirmRemove(null); setError(null); setWorktreeError(null); + setBranchError(null); return; } let cancelled = false; setLoading(true); setWorktreesLoading(true); + setBranchesLoading(true); + setBranches([]); setRemovingPath(null); setConfirmRemove(null); setError(null); setWorktreeError(null); + setBranchError(null); api .getProjectSettings(project.repoPath) @@ -299,6 +329,21 @@ export function ProjectSettingsModal({ if (!cancelled) setWorktreesLoading(false); }); + api + .listProjectBranches(project.repoPath) + .then((items) => { + if (cancelled) return; + setBranches(items); + }) + .catch((e) => { + if (cancelled) return; + setBranches([]); + setBranchError(String(e)); + }) + .finally(() => { + if (!cancelled) setBranchesLoading(false); + }); + return () => { cancelled = true; }; @@ -306,6 +351,56 @@ export function ProjectSettingsModal({ }, [project, projectRootsKey]); const prompt = settings.pull_requests.generation_prompt ?? ""; + const configuredBaseBranch = settings.worktrees.base_branch; + const configuredProjectBranch = branches.find( + (branch) => projectBranchReference(branch) === configuredBaseBranch, + ) ?? branches.find((branch) => branch.name === configuredBaseBranch); + const selectedBaseBranch = + configuredBaseBranch === null + ? AUTOMATIC_BASE_BRANCH_VALUE + : configuredProjectBranch + ? `${BRANCH_VALUE_PREFIX}${projectBranchReference(configuredProjectBranch)}` + : CUSTOM_BASE_BRANCH_VALUE; + const branchOptions = useMemo>(() => { + const localBranches: SelectItem[] = branches + .filter((branch) => !branch.is_remote) + .map((branch) => ({ + value: `${BRANCH_VALUE_PREFIX}${projectBranchReference(branch)}`, + label: branch.name, + })); + const remoteBranches: SelectItem[] = branches + .filter((branch) => branch.is_remote) + .map((branch) => ({ + value: `${BRANCH_VALUE_PREFIX}${projectBranchReference(branch)}`, + label: branch.name, + })); + return [ + { + value: AUTOMATIC_BASE_BRANCH_VALUE, + label: dt(t, "dialogs.projectSettings.worktreeBaseBranchAutomatic"), + }, + ...(localBranches.length > 0 + ? [ + { + label: dt(t, "dialogs.projectSettings.localBranches"), + options: localBranches, + } satisfies SelectOptionGroup, + ] + : []), + ...(remoteBranches.length > 0 + ? [ + { + label: dt(t, "dialogs.projectSettings.remoteBranches"), + options: remoteBranches, + } satisfies SelectOptionGroup, + ] + : []), + { + value: CUSTOM_BASE_BRANCH_VALUE, + label: dt(t, "dialogs.projectSettings.worktreeBaseBranchCustom"), + }, + ]; + }, [branches, t]); function updatePrompt(value: string) { const next = Array.from(value).slice(0, PROMPT_MAX_CHARS).join(""); @@ -325,6 +420,39 @@ export function ProjectSettingsModal({ })); } + function updateWorktreeBaseBranch(value: string) { + const next = Array.from(value).slice(0, BRANCH_MAX_CHARS).join(""); + setSettings((current) => ({ + ...current, + worktrees: { + ...current.worktrees, + base_branch: next, + }, + })); + } + + function selectWorktreeBaseBranch(value: string) { + if (value === AUTOMATIC_BASE_BRANCH_VALUE) { + setSettings((current) => ({ + ...current, + worktrees: { ...current.worktrees, base_branch: null }, + })); + return; + } + if (value === CUSTOM_BASE_BRANCH_VALUE) { + if (selectedBaseBranch !== CUSTOM_BASE_BRANCH_VALUE) { + setSettings((current) => ({ + ...current, + worktrees: { ...current.worktrees, base_branch: "" }, + })); + } + return; + } + if (value.startsWith(BRANCH_VALUE_PREFIX)) { + updateWorktreeBaseBranch(value.slice(BRANCH_VALUE_PREFIX.length)); + } + } + async function save() { if (!project) return; setSaving(true); @@ -527,6 +655,57 @@ export function ProjectSettingsModal({ title={dt(t, "dialogs.projectSettings.worktrees")} description={dt(t, "dialogs.projectSettings.worktreesHint")} > + + + updateWorktreeBaseBranch(e.target.value) + } + disabled={loading || saving} + maxLength={BRANCH_MAX_CHARS} + aria-label={dt( + t, + "dialogs.projectSettings.customWorktreeBaseBranch", + )} + placeholder={dt( + t, + "dialogs.projectSettings.worktreeBaseBranchPlaceholder", + )} + className="w-full rounded-md border border-input-border bg-input px-2 py-1.5 font-mono text-xs text-fg outline-none transition focus:border-accent focus:bg-input-hover disabled:opacity-60" + /> + ) : null} + 1} worktrees={worktrees} diff --git a/src/components/PullRequestDetailModal.modal.test.tsx b/src/components/PullRequestDetailModal.modal.test.tsx index 454ecc86..4758188d 100644 --- a/src/components/PullRequestDetailModal.modal.test.tsx +++ b/src/components/PullRequestDetailModal.modal.test.tsx @@ -35,6 +35,7 @@ vi.mock("../lib/api", () => { generatePrCommitMessage: vi.fn(), getProjectSettings: vi.fn(), listProjectWorktrees: vi.fn(), + listProjectBranches: vi.fn(), removeWorktree: vi.fn(), updateProjectSettings: vi.fn(), }, @@ -112,6 +113,7 @@ describe("PullRequestDetailModal — body checkbox toggle", () => { mockApi.generatePrCommitMessage.mockReset(); mockApi.getProjectSettings.mockReset(); mockApi.listProjectWorktrees.mockReset(); + mockApi.listProjectBranches.mockReset(); mockApi.removeWorktree.mockReset(); mockApi.updateProjectSettings.mockReset(); mockApi.getProjectSettings.mockResolvedValue({ @@ -119,9 +121,11 @@ describe("PullRequestDetailModal — body checkbox toggle", () => { settings: { remember_after_close: true, pull_requests: { generation_prompt: null }, + worktrees: { base_branch: null }, }, }); mockApi.listProjectWorktrees.mockResolvedValue([]); + mockApi.listProjectBranches.mockResolvedValue([]); mockApi.removeWorktree.mockResolvedValue(null); window.localStorage.clear(); useSettings.setState({ settings: structuredClone(DEFAULT_SETTINGS) }); @@ -553,6 +557,7 @@ describe("PullRequestDetailModal — body checkbox toggle", () => { generation_prompt: "프로젝트 규칙대로 PR title과 comment를 한국어 릴리즈 노트 스타일로 작성해.", }, + worktrees: { base_branch: null }, }, }); mockApi.getPullRequestDetail.mockResolvedValueOnce({ diff --git a/src/lib/api.ts b/src/lib/api.ts index 28e1d6bd..d8c0f6d8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -25,6 +25,7 @@ import type { MemoryUsage, MergeMethod, Project, + ProjectBranch, ProjectSettings, ProjectSettingsRecord, ProjectWorktree, @@ -468,6 +469,9 @@ export const api = { listProjectWorktrees(repoPath: string): Promise { return invoke("list_project_worktrees", { repoPath }); }, + listProjectBranches(repoPath: string): Promise { + return invoke("list_project_branches", { repoPath }); + }, reorderProjects(order: string[]): Promise { return invoke("reorder_projects", { order }); }, diff --git a/src/lib/types.ts b/src/lib/types.ts index a39edd7c..bfdff0bf 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -568,13 +568,23 @@ export interface ProjectWorktree { modified_ms: number | null; } +export interface ProjectBranch { + name: string; + is_remote: boolean; +} + export interface ProjectPullRequestSettings { generation_prompt: string | null; } +export interface ProjectWorktreeSettings { + base_branch: string | null; +} + export interface ProjectSettings { remember_after_close: boolean; pull_requests: ProjectPullRequestSettings; + worktrees: ProjectWorktreeSettings; } export interface ProjectSettingsRecord { diff --git a/src/locales/en.json b/src/locales/en.json index e64487cc..43e45e9a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2197,7 +2197,18 @@ "pullRequests": "Pull requests", "pullRequestsHint": "Project-specific defaults for pull request workflows.", "worktrees": "Worktrees", - "worktreesHint": "Linked git worktrees registered for this project.", + "worktreesHint": "Defaults and linked git worktrees for this project.", + "worktreeBaseBranch": "Base branch for new worktrees", + "worktreeBaseBranchHint": "Choose a local or remote-tracking branch, or enter another branch name. Automatic chooses main, master, or the current HEAD.", + "worktreeBaseBranchAutomatic": "Automatic", + "worktreeBaseBranchCustom": "Enter a branch name…", + "customWorktreeBaseBranch": "Custom base branch", + "worktreeBaseBranchPlaceholder": "develop or origin/develop", + "localBranches": "Local branches", + "remoteBranches": "Remote branches", + "searchBranches": "Search branches", + "loadingBranches": "Loading branches…", + "loadBranchesFailed": "Failed to load branches:", "generationPrompt": "Prompt for generated PR titles, comments, and merge messages", "generationPromptHint": "Used when Acorn asks the selected AI CLI to generate this project's pull request title, review comment, or merge message.", "generationPromptPlaceholder": "Example: Use a standard GitHub-style pull request merge message with a Conventional Commit subject and a concise impact-focused body.", diff --git a/src/locales/ja.json b/src/locales/ja.json index 5ddb71cb..9d693899 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -2197,7 +2197,18 @@ "pullRequests": "プルリクエスト", "pullRequestsHint": "プル リクエスト ワークフローのプロジェクト固有のデフォルト。", "worktrees": "ワークツリー", - "worktreesHint": "このプロジェクトに登録されたリンクされた git ワークツリー。", + "worktreesHint": "このプロジェクトの既定値とリンクされた Git ワークツリー。", + "worktreeBaseBranch": "新しいワークツリーのベースブランチ", + "worktreeBaseBranchHint": "ローカルまたはリモート追跡ブランチを選択するか、別のブランチ名を入力します。自動選択では main、master、または現在の HEAD を使用します。", + "worktreeBaseBranchAutomatic": "自動選択", + "worktreeBaseBranchCustom": "ブランチ名を直接入力…", + "customWorktreeBaseBranch": "カスタムベースブランチ", + "worktreeBaseBranchPlaceholder": "develop または origin/develop", + "localBranches": "ローカルブランチ", + "remoteBranches": "リモートブランチ", + "searchBranches": "ブランチを検索", + "loadingBranches": "ブランチを読み込み中…", + "loadBranchesFailed": "ブランチを読み込めませんでした:", "generationPrompt": "生成された PR タイトル、コメント、およびマージ メッセージの入力を求めるプロンプト", "generationPromptHint": "Acorn が選択された AI CLI にこのプロジェクトのプル リクエスト タイトル、レビュー コメント、またはマージ メッセージを生成するよう要求するときに使用されます。", "generationPromptPlaceholder": "例: 従来のコミットの件名と影響を重視した簡潔な本文を含む、標準の GitHub スタイルのプル リクエスト マージ メッセージを使用します。", diff --git a/src/locales/ko.json b/src/locales/ko.json index 014395c0..83f58946 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -2197,7 +2197,18 @@ "pullRequests": "Pull request", "pullRequestsHint": "이 프로젝트의 pull request 작업에만 적용되는 기본값입니다.", "worktrees": "워크트리", - "worktreesHint": "이 프로젝트에 등록된 연결 Git 워크트리입니다.", + "worktreesHint": "이 프로젝트의 기본값과 연결 Git 워크트리입니다.", + "worktreeBaseBranch": "새 워크트리의 기준 브랜치", + "worktreeBaseBranchHint": "로컬 또는 원격 추적 브랜치를 선택하거나 다른 브랜치 이름을 직접 입력하세요. 자동 선택은 main, master 또는 현재 HEAD를 사용합니다.", + "worktreeBaseBranchAutomatic": "자동 선택", + "worktreeBaseBranchCustom": "브랜치 이름 직접 입력…", + "customWorktreeBaseBranch": "직접 입력할 기준 브랜치", + "worktreeBaseBranchPlaceholder": "develop 또는 origin/develop", + "localBranches": "로컬 브랜치", + "remoteBranches": "원격 브랜치", + "searchBranches": "브랜치 검색", + "loadingBranches": "브랜치를 불러오는 중…", + "loadBranchesFailed": "브랜치를 불러오지 못했습니다:", "generationPrompt": "PR 제목, 코멘트, 머지 메시지 생성 프롬프트", "generationPromptHint": "Acorn이 선택된 AI CLI로 이 프로젝트의 pull request 제목, 리뷰 코멘트, 머지 메시지를 생성할 때 사용합니다.", "generationPromptPlaceholder": "예: Conventional Commit 제목과 영향 중심 본문을 사용하는 표준 GitHub 스타일 pull request merge message로 작성하세요.", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 3941c7e1..39dc844c 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -2197,7 +2197,18 @@ "pullRequests": "拉取请求", "pullRequestsHint": "项目特定的默认值为拉取请求工作流。", "worktrees": "工作树", - "worktreesHint": "为此项目注册的链接 Git 工作树。", + "worktreesHint": "此项目的默认值和已注册的链接 Git 工作树。", + "worktreeBaseBranch": "新工作树的基准分支", + "worktreeBaseBranchHint": "选择本地或远程跟踪分支,或输入其他分支名称。自动选择将使用 main、master 或当前 HEAD。", + "worktreeBaseBranchAutomatic": "自动选择", + "worktreeBaseBranchCustom": "直接输入分支名称…", + "customWorktreeBaseBranch": "自定义基准分支", + "worktreeBaseBranchPlaceholder": "develop 或 origin/develop", + "localBranches": "本地分支", + "remoteBranches": "远程分支", + "searchBranches": "搜索分支", + "loadingBranches": "正在加载分支…", + "loadBranchesFailed": "无法加载分支:", "generationPrompt": "生成 PR 标题、评论和合并消息的提示词", "generationPromptHint": "当 Acorn 要求选定的 AI CLI 生成此项目的拉取请求标题、审阅注释或合并消息时使用。", "generationPromptPlaceholder": "示例:使用标准 GitHub 风格的拉取请求合并消息,包含 Conventional Commit 主题和简洁、突出影响的正文。", diff --git a/tests/e2e/fixtures/tauriMock.ts b/tests/e2e/fixtures/tauriMock.ts index 14078deb..de1209c0 100644 --- a/tests/e2e/fixtures/tauriMock.ts +++ b/tests/e2e/fixtures/tauriMock.ts @@ -256,6 +256,7 @@ export const tauriMockSource = ` settings: { remember_after_close: true, pull_requests: { generation_prompt: standardPrGenerationPrompt }, + worktrees: { base_branch: null }, }, }); } @@ -265,6 +266,7 @@ export const tauriMockSource = ` settings: args?.settings || { remember_after_close: true, pull_requests: { generation_prompt: standardPrGenerationPrompt }, + worktrees: { base_branch: null }, }, }); } @@ -542,6 +544,7 @@ export const tauriMockSource = ` if (cmd === 'pty_in_worktree_all') return Promise.resolve({}); if (cmd === 'is_path_linked_worktree') return Promise.resolve(false); if (cmd === 'list_project_worktrees') return Promise.resolve([]); + if (cmd === 'list_project_branches') return Promise.resolve([]); if (cmd === 'remove_worktree') return Promise.resolve(null); if (cmd === 'restore_removed_worktree') return Promise.resolve(undefined); if (cmd === 'discard_removed_worktree') return Promise.resolve(undefined); diff --git a/tests/e2e/project-settings.spec.ts b/tests/e2e/project-settings.spec.ts index 4af21249..ed36e953 100644 --- a/tests/e2e/project-settings.spec.ts +++ b/tests/e2e/project-settings.spec.ts @@ -220,6 +220,141 @@ test.describe("project settings", () => { ]); }); + test("selects a detected base branch for new project worktrees", async ({ + page, + tauri, + }) => { + await tauri.respond("list_projects", [ + { + repo_path: "/tmp/acorn", + name: "acorn", + created_at: "2026-01-01T00:00:00Z", + position: 0, + }, + ]); + await tauri.respond("get_project_settings", { + key: "path:/tmp/acorn", + settings: { + remember_after_close: true, + pull_requests: { generation_prompt: null }, + worktrees: { base_branch: "release/2026" }, + }, + }); + await tauri.respond("list_project_branches", [ + { name: "develop", is_remote: false }, + { name: "release/2026", is_remote: false }, + { name: "origin/main", is_remote: true }, + ]); + await tauri.handle("update_project_settings", (args) => { + const w = window as unknown as { __projectSettingsCalls?: unknown[] }; + w.__projectSettingsCalls = w.__projectSettingsCalls ?? []; + w.__projectSettingsCalls.push(args); + return { + key: "path:/tmp/acorn", + settings: (args as { settings: unknown }).settings, + }; + }); + + await page.goto("/"); + await page + .getByRole("button", { name: "Project acorn" }) + .click({ button: "right" }); + await page.getByRole("menuitem", { name: "Project Settings" }).click(); + + const modal = page.getByRole("dialog", { name: "Project Settings" }); + await modal.getByRole("button", { name: "Worktrees" }).click(); + const baseBranch = modal.getByRole("combobox", { + name: "Base branch for new worktrees", + }); + await expect(baseBranch).toContainText("release/2026"); + await baseBranch.click(); + await page.getByRole("option", { name: "develop", exact: true }).click(); + await modal.getByRole("button", { name: "Save" }).click(); + + await expect(modal).toHaveCount(0); + const calls = (await page.evaluate( + () => + (window as unknown as { __projectSettingsCalls?: unknown[] }) + .__projectSettingsCalls, + )) as Array<{ repoPath: string; settings: unknown }>; + expect(calls).toEqual([ + { + repoPath: "/tmp/acorn", + settings: { + remember_after_close: true, + pull_requests: { generation_prompt: null }, + worktrees: { base_branch: "refs/heads/develop" }, + }, + }, + ]); + }); + + test("accepts a base branch name that is not in the detected list", async ({ + page, + tauri, + }) => { + await tauri.respond("list_projects", [ + { + repo_path: "/tmp/acorn", + name: "acorn", + created_at: "2026-01-01T00:00:00Z", + position: 0, + }, + ]); + await tauri.respond("get_project_settings", { + key: "path:/tmp/acorn", + settings: { + remember_after_close: true, + pull_requests: { generation_prompt: null }, + worktrees: { base_branch: null }, + }, + }); + await tauri.respond("list_project_branches", [ + { name: "main", is_remote: false }, + { name: "origin/main", is_remote: true }, + ]); + await tauri.handle("update_project_settings", (args) => { + const w = window as unknown as { __projectSettingsCalls?: unknown[] }; + w.__projectSettingsCalls = w.__projectSettingsCalls ?? []; + w.__projectSettingsCalls.push(args); + return { + key: "path:/tmp/acorn", + settings: (args as { settings: unknown }).settings, + }; + }); + + await page.goto("/"); + await page + .getByRole("button", { name: "Project acorn" }) + .click({ button: "right" }); + await page.getByRole("menuitem", { name: "Project Settings" }).click(); + + const modal = page.getByRole("dialog", { name: "Project Settings" }); + await modal.getByRole("button", { name: "Worktrees" }).click(); + await modal + .getByRole("combobox", { name: "Base branch for new worktrees" }) + .click(); + await page.getByRole("option", { name: "Enter a branch name…" }).click(); + await modal + .getByRole("textbox", { name: "Custom base branch" }) + .fill("team/integration"); + await modal.getByRole("button", { name: "Save" }).click(); + + const calls = (await page.evaluate( + () => + (window as unknown as { __projectSettingsCalls?: unknown[] }) + .__projectSettingsCalls, + )) as Array<{ repoPath: string; settings: unknown }>; + expect(calls[0]).toEqual({ + repoPath: "/tmp/acorn", + settings: { + remember_after_close: true, + pull_requests: { generation_prompt: null }, + worktrees: { base_branch: "team/integration" }, + }, + }); + }); + test("confirms before deleting a worktree used by the active sidebar session", async ({ page, tauri,