diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index bd4f1749..94ef5cca 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -809,10 +809,22 @@ pub async fn setup_worktree( ) .map_err(|e| e.to_string())? } else { + // If the branch exists on the remote (e.g. from an existing PR), + // start the new local branch from the remote tracking ref so it + // includes the PR's commits. Otherwise fall back to base_branch + // for genuinely new branches. + let remote_ref = format!("origin/{}", branch.branch_name); + let start_point = if git::remote_branch_exists(&repo_path, &branch.branch_name) + .map_err(|e| e.to_string())? + { + &remote_ref + } else { + &branch.base_branch + }; match create_worktree_with_fallback( &repo_path, &branch.branch_name, - &branch.base_branch, + start_point, &desired_worktree_path, ) { Ok(path) => path, @@ -1530,10 +1542,22 @@ pub(crate) fn setup_worktree_sync(store: &Arc, branch_id: &str) -> Result ) .map_err(|e| e.to_string())? } else { + // If the branch exists on the remote (e.g. from an existing PR), + // start the new local branch from the remote tracking ref so it + // includes the PR's commits. Otherwise fall back to base_branch + // for genuinely new branches. + let remote_ref = format!("origin/{}", branch.branch_name); + let start_point = if crate::git::remote_branch_exists(&repo_path, &branch.branch_name) + .map_err(|e| e.to_string())? + { + &remote_ref + } else { + &branch.base_branch + }; match crate::git::create_worktree_at_path( &repo_path, &branch.branch_name, - &branch.base_branch, + start_point, &desired_worktree_path, ) { Ok(path) => path, diff --git a/apps/staged/src-tauri/src/git/mod.rs b/apps/staged/src-tauri/src/git/mod.rs index bc2d1076..a9f65f18 100644 --- a/apps/staged/src-tauri/src/git/mod.rs +++ b/apps/staged/src-tauri/src/git/mod.rs @@ -32,6 +32,6 @@ pub use worktree::{ create_worktree_for_existing_branch_at_path, create_worktree_from_pr, create_worktree_from_pr_at_path, get_commits_since_base, get_full_commit_log, get_head_sha, get_parent_commit, has_unpushed_commits, list_worktrees, project_worktree_path_for, - project_worktree_root_for, remove_worktree, reset_to_commit, switch_branch, - update_branch_from_pr, worktree_path_for, CommitInfo, UpdateFromPrResult, + project_worktree_root_for, remote_branch_exists, remove_worktree, reset_to_commit, + switch_branch, update_branch_from_pr, worktree_path_for, CommitInfo, UpdateFromPrResult, }; diff --git a/apps/staged/src-tauri/src/git/worktree.rs b/apps/staged/src-tauri/src/git/worktree.rs index b0fe43d6..a684a8b6 100644 --- a/apps/staged/src-tauri/src/git/worktree.rs +++ b/apps/staged/src-tauri/src/git/worktree.rs @@ -397,6 +397,13 @@ pub fn branch_exists(repo: &Path, branch_name: &str) -> Result { Ok(result.is_ok()) } +/// Check whether a remote tracking branch (`origin/`) exists. +pub fn remote_branch_exists(repo: &Path, branch_name: &str) -> Result { + let ref_name = format!("refs/remotes/origin/{branch_name}"); + let result = cli::run(repo, &["rev-parse", "--verify", &ref_name]); + Ok(result.is_ok()) +} + /// Reset HEAD to a specific commit (hard reset). /// This discards all commits after the specified commit. pub fn reset_to_commit(worktree: &Path, commit_sha: &str) -> Result<(), GitError> { diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 18b8c589..338d165d 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -218,6 +218,7 @@ fn list_projects( .map_err(|e| e.to_string()) } +#[allow(clippy::too_many_arguments)] #[tauri::command(rename_all = "camelCase")] fn create_project( store: tauri::State<'_, Mutex>>>, @@ -226,6 +227,8 @@ fn create_project( github_repo: Option, location: Option, subpath: Option, + branch_name: Option, + pr_number: Option, ) -> Result { let store = get_store(&store)?; let trimmed = name.trim(); @@ -243,7 +246,12 @@ fn create_project( Some("remote") => store::ProjectLocation::Remote, _ => store::ProjectLocation::Local, }; - let inferred_branch_name = branches::infer_branch_name(trimmed); + let inferred_branch_name = branch_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| branches::infer_branch_name(trimmed)); let mut project = store::Project::named(trimmed); project.location = project_location; if let Some(repo) = github_repo.clone() { @@ -292,21 +300,27 @@ fn create_project( let branch_id = match project.location { store::ProjectLocation::Local => { - let branch = + let mut branch = store::Branch::new(&project.id, &inferred_branch_name, &effective_base) .with_project_repo(&project_repo.id); + if let Some(pr) = pr_number { + branch = branch.with_pr(pr); + } store.create_branch(&branch).map_err(|e| e.to_string())?; Some(branch.id) } store::ProjectLocation::Remote => { let workspace_name = branches::infer_workspace_name(&inferred_branch_name); - let branch = store::Branch::new_remote( + let mut branch = store::Branch::new_remote( &project.id, &inferred_branch_name, &effective_base, &workspace_name, ) .with_project_repo(&project_repo.id); + if let Some(pr) = pr_number { + branch = branch.with_pr(pr); + } store.create_branch(&branch).map_err(|e| e.to_string())?; log::info!( "[create_project] created remote branch={} workspace={} status=starting project={}", @@ -401,6 +415,7 @@ fn list_recent_repos( .map_err(|e| e.to_string()) } +#[allow(clippy::too_many_arguments)] #[tauri::command(rename_all = "camelCase")] async fn add_project_repo( store: tauri::State<'_, Mutex>>>, @@ -410,6 +425,7 @@ async fn add_project_repo( branch_name: Option, subpath: Option, set_as_primary: Option, + pr_number: Option, ) -> Result { let store = get_store(&store)?; let repo = project_commands::add_project_repo_impl( @@ -420,6 +436,7 @@ async fn add_project_repo( subpath, set_as_primary, None, + pr_number, ) .await?; diff --git a/apps/staged/src-tauri/src/project_commands.rs b/apps/staged/src-tauri/src/project_commands.rs index 16248f14..3ebb80e5 100644 --- a/apps/staged/src-tauri/src/project_commands.rs +++ b/apps/staged/src-tauri/src/project_commands.rs @@ -8,6 +8,7 @@ use crate::{blox, branches, git}; /// Core logic for adding a GitHub repository to a project. /// /// Called by both the `add_project_repo` Tauri command and the MCP tool. +#[allow(clippy::too_many_arguments)] pub(crate) async fn add_project_repo_impl( store: Arc, project_id: String, @@ -16,6 +17,7 @@ pub(crate) async fn add_project_repo_impl( subpath: Option, set_as_primary: Option, reason: Option, + pr_number: Option, ) -> Result { let project = store .get_project(&project_id) @@ -121,13 +123,26 @@ pub(crate) async fn add_project_repo_impl( }; let branch = match project.location { store::ProjectLocation::Local => { - store::Branch::new(&project_id, &repo.branch_name, &effective_base) - .with_project_repo(&repo.id) + let mut b = store::Branch::new(&project_id, &repo.branch_name, &effective_base) + .with_project_repo(&repo.id); + if let Some(pr) = pr_number { + b = b.with_pr(pr); + } + b } store::ProjectLocation::Remote => { let ws_name = branches::resolve_project_workspace_name(&store, &project, None)?; - store::Branch::new_remote(&project_id, &repo.branch_name, &effective_base, &ws_name) - .with_project_repo(&repo.id) + let mut b = store::Branch::new_remote( + &project_id, + &repo.branch_name, + &effective_base, + &ws_name, + ) + .with_project_repo(&repo.id); + if let Some(pr) = pr_number { + b = b.with_pr(pr); + } + b } }; store.create_branch(&branch).map_err(|e| e.to_string())?; diff --git a/apps/staged/src-tauri/src/project_mcp.rs b/apps/staged/src-tauri/src/project_mcp.rs index 684c9259..a82b0d1d 100644 --- a/apps/staged/src-tauri/src/project_mcp.rs +++ b/apps/staged/src-tauri/src/project_mcp.rs @@ -589,6 +589,7 @@ impl ProjectToolsHandler { p.subpath, None, p.reason, + None, ) .await { diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 275c5590..464fb650 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -57,9 +57,18 @@ export function createProject( name: string, location: 'local' | 'remote', githubRepo?: string, - subpath?: string + subpath?: string, + branchName?: string, + prNumber?: number ): Promise { - return invoke('create_project', { name, location, githubRepo: githubRepo ?? null, subpath }); + return invoke('create_project', { + name, + location, + githubRepo: githubRepo ?? null, + subpath, + branchName: branchName ?? null, + prNumber: prNumber ?? null, + }); } export function deleteProject(id: string): Promise { @@ -79,7 +88,8 @@ export function addProjectRepo( githubRepo: string, branchName?: string, subpath?: string, - setAsPrimary?: boolean + setAsPrimary?: boolean, + prNumber?: number ): Promise { return invoke('add_project_repo', { projectId, @@ -87,6 +97,7 @@ export function addProjectRepo( branchName: branchName ?? null, subpath: subpath ?? null, setAsPrimary: setAsPrimary ?? null, + prNumber: prNumber ?? null, }); } diff --git a/apps/staged/src/lib/features/projects/BranchPicker.svelte b/apps/staged/src/lib/features/projects/BranchPicker.svelte new file mode 100644 index 00000000..507f9c8f --- /dev/null +++ b/apps/staged/src/lib/features/projects/BranchPicker.svelte @@ -0,0 +1,459 @@ + + + +
+
+ +
+ + {#if showDropdown && filteredItems.length > 0 && !disabled} +
+ {#each filteredItems as item, i} + + {/each} +
+ {:else if showDropdown && loading && !disabled} +
+
Loading…
+
+ {/if} + + {#if matchedPr} +
+ +
+ #{matchedPr.number} {matchedPr.title} + {matchedPr.author} · {matchedPr.baseRef}{#if matchedPr.draft} + · Draft{/if} +
+
+ {/if} +
+ + diff --git a/apps/staged/src/lib/features/projects/GitHubRepoPicker.svelte b/apps/staged/src/lib/features/projects/GitHubRepoPicker.svelte index 49f8c370..238bfb11 100644 --- a/apps/staged/src/lib/features/projects/GitHubRepoPicker.svelte +++ b/apps/staged/src/lib/features/projects/GitHubRepoPicker.svelte @@ -10,9 +10,10 @@ import Spinner from '../../shared/Spinner.svelte'; import * as commands from '../../api/commands'; import type { GitHubRepo, RecentRepo } from '../../types'; + import { parseGitHubUrl, type RepoSelection } from '../../shared/githubUrl'; interface Props { - onSelect: (nameWithOwner: string, subpath?: string) => void; + onSelect: (selection: RepoSelection) => void; onBack: () => void; excludeRepos?: Set; showHeader?: boolean; @@ -31,14 +32,6 @@ let isSearching = $state(false); let directFetchRepo = $state(null); - function parseGitHubUrl(input: string): string | null { - const trimmed = input.trim(); - const match = trimmed.match( - /^(?:https?:\/\/)?github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\/.*|\.git)?$/ - ); - return match ? match[1] : null; - } - function isOwnerRepoFormat(input: string): boolean { const trimmed = input.trim(); return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(trimmed); @@ -122,6 +115,19 @@ const parsed = parseGitHubUrl(trimmed); if (parsed) { + if (parsed.prNumber) { + // PR URL: resolve the PR's head branch before selecting + try { + const prs = await commands.listPullRequests(parsed.nameWithOwner); + const pr = prs.find((p) => p.number === parsed.prNumber); + if (pr) { + onSelect({ ...parsed, branchName: pr.headRef }); + return; + } + } catch { + // Couldn't fetch PRs — fall through with just the repo + } + } onSelect(parsed); return; } @@ -207,7 +213,7 @@ bind:this={searchInputEl} bind:value={query} type="text" - placeholder="Search or paste a repository..." + placeholder="Search or paste a repo or PR link..." autocomplete="off" autocorrect="off" spellcheck="false" @@ -221,7 +227,8 @@