diff --git a/.announcements/git-no-remote.json b/.announcements/git-no-remote.json new file mode 100644 index 000000000..b679d01ef --- /dev/null +++ b/.announcements/git-no-remote.json @@ -0,0 +1,7 @@ +{ + "items": [ + { + "text": "Open project now works on local git repositories with no remote." + } + ] +} diff --git a/.changeset/git-no-remote-support.md b/.changeset/git-no-remote-support.md new file mode 100644 index 000000000..198058898 --- /dev/null +++ b/.changeset/git-no-remote-support.md @@ -0,0 +1,8 @@ +--- +"helmor": minor +--- + +Support local git repositories that have no remote, including worktree workspaces. + +- Open a git repo with no remote configured — branches, diffs, commits, and worktrees all work, with the default branch read from local HEAD. +- Push, pull, fetch, pull requests, and the forge Connect prompts are hidden for these local-only repos; repo settings show a "Local-only repository" notice and a local-branch picker. diff --git a/src-tauri/src/commands/tests/repository.rs b/src-tauri/src/commands/tests/repository.rs index d5572cfda..0a0fcbe04 100644 --- a/src-tauri/src/commands/tests/repository.rs +++ b/src-tauri/src/commands/tests/repository.rs @@ -150,7 +150,7 @@ fn add_repository_from_local_path_attaches_non_git_directory() { } #[test] -fn add_repository_from_local_path_still_rejects_git_repo_without_remote() { +fn add_repository_from_local_path_accepts_git_repo_without_remote() { let _guard = TEST_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -179,11 +179,28 @@ fn add_repository_from_local_path_still_rejects_git_repo_without_remote() { ) .unwrap(); - let error = repos::add_repository_from_local_path(root).unwrap_err(); - assert!( - error.to_string().contains("Local-only repositories"), - "expected local-only git repo validation to still reject, got: {error}" - ); + // A git repo without a remote is now supported (local-only): remote is + // None, default branch comes from local HEAD, and it stays a git repo + // (default_branch is Some, so it is NOT treated as a non-git directory). + let response = repos::add_repository_from_local_path(root).unwrap(); + assert!(response.created_repository); + + let repo = repos::load_repository_by_id(&response.repository_id) + .unwrap() + .unwrap(); + assert_eq!(repo.remote, None); + assert_eq!(repo.default_branch.as_deref(), Some("main")); + + let prepared = workspaces::prepare_local_workspace_impl( + &response.repository_id, + None, + crate::workspace_status::WorkspaceStatus::InProgress, + None, + ) + .unwrap(); + // Has git context (real branch), so it is a Local git workspace, not "Files". + assert_eq!(prepared.branch, "main"); + assert_eq!(prepared.state, WorkspaceState::Ready); } #[test] diff --git a/src-tauri/src/commands/tests/support.rs b/src-tauri/src/commands/tests/support.rs index b5acdd7ea..0bec5646d 100644 --- a/src-tauri/src/commands/tests/support.rs +++ b/src-tauri/src/commands/tests/support.rs @@ -716,10 +716,14 @@ fn create_workspace_fixture_db( // produces the `testuser/` branch the assertions expect. connection .execute( + // `init_create_git_repo` adds an `origin` remote, so the repo row + // records it too — matching what `resolve_repository_from_local_path` + // stores for a real repo. `repos.remote` is the source of truth for + // "has a remote". r#"INSERT INTO repos ( - id, remote_url, name, default_branch, root_path, display_order, hidden, + id, remote, remote_url, name, default_branch, root_path, display_order, hidden, branch_prefix_type, branch_prefix_custom - ) VALUES (?1, NULL, ?2, 'main', ?3, 1, 0, 'custom', 'testuser/')"#, + ) VALUES (?1, 'origin', NULL, ?2, 'main', ?3, 1, 0, 'custom', 'testuser/')"#, (repo_id, repo_name, stored_root_path), ) .unwrap(); @@ -838,7 +842,9 @@ fn create_branch_switch_fixture_db( let connection = open_fixture_db(db_path); connection .execute( - "INSERT INTO repos (id, name, remote_url, default_branch, root_path) VALUES (?1, ?2, NULL, 'main', ?3)", + // The branch-switch harness clones the source, so the working + // repo has an `origin` remote — record it (SSOT for "has remote"). + "INSERT INTO repos (id, name, remote, remote_url, default_branch, root_path) VALUES (?1, ?2, 'origin', NULL, 'main', ?3)", ["repo-1", repo_name, source_repo.to_str().unwrap()], ) .unwrap(); diff --git a/src-tauri/src/commands/tests/workspace_creation.rs b/src-tauri/src/commands/tests/workspace_creation.rs index f27cd9dd0..655703da5 100644 --- a/src-tauri/src/commands/tests/workspace_creation.rs +++ b/src-tauri/src/commands/tests/workspace_creation.rs @@ -1043,6 +1043,67 @@ fn finalize_workspace_transitions_initializing_to_ready_and_creates_worktree() { assert_eq!(state, "ready"); } +#[test] +fn worktree_for_no_remote_repo_forks_from_local_branch() { + let _guard = TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let harness = CreateTestHarness::new(); + + // A git repo with commits but NO remote configured. + let repo_dir = harness.root.join("local-only-repo"); + fs::create_dir_all(&repo_dir).unwrap(); + let root = repo_dir.to_str().unwrap(); + git_ops::run_git(["init", "-b", "main", root], None).unwrap(); + fs::write(repo_dir.join("README.md"), "hi").unwrap(); + git_ops::run_git(["-C", root, "add", "README.md"], None).unwrap(); + git_ops::run_git( + [ + "-C", + root, + "-c", + "commit.gpgsign=false", + "-c", + "user.name=Helmor", + "-c", + "user.email=helmor@example.com", + "commit", + "-m", + "initial", + ], + None, + ) + .unwrap(); + + let response = repos::add_repository_from_local_path(root).unwrap(); + let repo = repos::load_repository_by_id(&response.repository_id) + .unwrap() + .unwrap(); + assert_eq!(repo.remote, None); + assert_eq!(repo.default_branch.as_deref(), Some("main")); + + // Worktree mode forks a new branch off the local default — no remote. + let prepared = workspaces::prepare_workspace_from_repo_impl( + &response.repository_id, + None, + WorkspaceBranchIntent::FromBranch, + WorkspaceStatus::InProgress, + None, + ) + .unwrap(); + let finalized = workspaces::finalize_workspace_from_repo_impl(&prepared.workspace_id).unwrap(); + assert_eq!(finalized.final_state, WorkspaceState::Ready); + + let workspace_dir = std::path::PathBuf::from(&finalized.working_directory); + assert!( + workspace_dir.join(".git").exists(), + "worktree should be materialised from the local branch" + ); + // The worktree checked out the freshly forked branch. + let head = git_ops::current_branch_name(&workspace_dir).unwrap(); + assert_eq!(head, prepared.branch); +} + #[test] fn finalize_workspace_reports_setup_pending_when_helmor_json_has_setup() { let _guard = TEST_LOCK @@ -2321,7 +2382,7 @@ fn list_branch_picker_entries_tags_local_and_remote_correctly() { let root = harness.source_repo_root.to_str().unwrap(); git_ops::run_git(["-C", root, "branch", "wip/local-only"], None).unwrap(); - let entries = workspaces::list_branch_picker_entries(&harness.source_repo_root, "origin"); + let entries = workspaces::list_branch_picker_entries(&harness.source_repo_root, Some("origin")); let by_name: std::collections::HashMap<&str, (bool, bool)> = entries .iter() diff --git a/src-tauri/src/commands/workspace_commands.rs b/src-tauri/src/commands/workspace_commands.rs index b04058e14..6e56e56de 100644 --- a/src-tauri/src/commands/workspace_commands.rs +++ b/src-tauri/src/commands/workspace_commands.rs @@ -199,11 +199,13 @@ pub async fn list_branches_for_local_picker(repo_id: String) -> CmdResult String { format!("refs/remotes/{remote}/{default_branch}") } +/// The repository's remote name, or `None` when it has no remote (local-only +/// git, or a non-git folder). Trim-empty-aware. This is the single source of +/// truth for "does this repo have a remote" — callers must handle `None` +/// explicitly (skip push/pull/PR, fall back to local refs) rather than +/// defaulting a missing remote to the literal `"origin"`. +pub fn remote_name(remote: &Option) -> Option<&str> { + remote + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + pub fn tracked_file_count(workspace_dir: &Path) -> Result { let workspace_dir = workspace_dir.display().to_string(); let output = run_git(["-C", workspace_dir.as_str(), "ls-files"], None).with_context(|| { diff --git a/src-tauri/src/models/repos.rs b/src-tauri/src/models/repos.rs index 6a36d4c5b..04c2cdb1f 100644 --- a/src-tauri/src/models/repos.rs +++ b/src-tauri/src/models/repos.rs @@ -1386,10 +1386,10 @@ pub fn resolve_repository_from_local_path(folder_path: &str) -> Result Some(resolve_repository_remote_url(normalized_root, remote_name)?), None => None, @@ -1397,17 +1397,19 @@ pub fn resolve_repository_from_local_path(folder_path: &str) -> Result Result) -> Option { - let remote = remote?; - resolve_default_branch_from_remote_head(repo_root, remote).ok() + if let Some(remote) = remote { + if let Ok(branch) = resolve_default_branch_from_remote_head(repo_root, remote) { + return Some(branch); + } + } + // No remote (or its HEAD is unavailable): use the local current branch, + // falling back to the first local branch for a detached/unborn HEAD. + resolve_local_default_branch(repo_root) +} + +/// The repo's local default branch: current branch, else the first local +/// branch. `None` only for an empty repo with no branches at all. +fn resolve_local_default_branch(repo_root: &Path) -> Option { + if let Ok(branch) = git_ops::current_branch_name(repo_root) { + return Some(branch); + } + git_ops::list_local_branches(repo_root) + .ok() + .and_then(|branches| branches.into_iter().next()) } fn resolve_default_branch_from_remote_head(repo_root: &Path, remote: &str) -> Result { diff --git a/src-tauri/src/workspace/branching.rs b/src-tauri/src/workspace/branching.rs index e80f5d4ed..582875aaf 100644 --- a/src-tauri/src/workspace/branching.rs +++ b/src-tauri/src/workspace/branching.rs @@ -20,7 +20,8 @@ use crate::{ struct RepoContext { root: PathBuf, - remote: String, + /// `None` for local-only repositories (no remote configured). + remote: Option, } /// Resolve the repository root and remote from either a workspace_id or a repo_id. @@ -32,13 +33,13 @@ fn resolve_repo_context(workspace_id: Option<&str>, repo_id: Option<&str>) -> Re let root = helpers::non_empty(&record.root_path) .map(PathBuf::from) .with_context(|| format!("Workspace {ws_id} is missing repo root_path"))?; - let remote = record.remote.unwrap_or_else(|| "origin".to_string()); + let remote = git_ops::remote_name(&record.remote).map(str::to_owned); Ok(RepoContext { root, remote }) } (_, Some(r_id)) => { let repo = crate::repos::load_repository_by_id(r_id)? .with_context(|| format!("Repository not found: {r_id}"))?; - let remote = repo.remote.unwrap_or_else(|| "origin".to_string()); + let remote = git_ops::remote_name(&repo.remote).map(str::to_owned); Ok(RepoContext { root: PathBuf::from(repo.root_path.trim()), remote, @@ -54,7 +55,11 @@ pub fn list_remote_branches( ) -> Result> { let ctx = resolve_repo_context(workspace_id, repo_id)?; git_ops::ensure_git_repository(&ctx.root)?; - git_ops::list_remote_branches(&ctx.root, &ctx.remote) + // Local-only repos have no remote branches. + let Some(remote) = ctx.remote.as_deref() else { + return Ok(Vec::new()); + }; + git_ops::list_remote_branches(&ctx.root, remote) } /// One row for the start-page branch picker. @@ -68,15 +73,20 @@ pub struct BranchPickerEntry { /// Merge local + remote branches into `{name, hasLocal, hasRemote}` rows, /// sorted by name. Pure local fs reads (no network). -pub fn list_branch_picker_entries(repo_root: &Path, remote: &str) -> Vec { +pub fn list_branch_picker_entries( + repo_root: &Path, + remote: Option<&str>, +) -> Vec { use std::collections::BTreeMap; let mut by_name: BTreeMap = BTreeMap::new(); for name in git_ops::list_local_branches(repo_root).unwrap_or_default() { by_name.entry(name).or_insert((false, false)).0 = true; } - for name in git_ops::list_remote_branches(repo_root, remote).unwrap_or_default() { - by_name.entry(name).or_insert((false, false)).1 = true; + if let Some(remote) = remote { + for name in git_ops::list_remote_branches(repo_root, remote).unwrap_or_default() { + by_name.entry(name).or_insert((false, false)).1 = true; + } } by_name .into_iter() @@ -248,7 +258,10 @@ fn try_realign_local_branch( return Ok(None); } - let remote = record.remote.as_deref().unwrap_or("origin"); + // Local-only repos have no remote to realign against. + let Some(remote) = git_ops::remote_name(&record.remote) else { + return Ok(None); + }; if !matches!( git_ops::verify_remote_ref_exists(&workspace_dir, remote, target_branch), @@ -293,7 +306,10 @@ pub fn refresh_remote_and_realign( return Ok(false); } - let remote = record.remote.as_deref().unwrap_or("origin"); + // Local-only repos have no remote to fetch/realign from. + let Some(remote) = git_ops::remote_name(&record.remote) else { + return Ok(false); + }; if git_ops::fetch_remote_branch(&workspace_dir, remote, target_branch).is_err() { return Ok(false); } @@ -328,7 +344,9 @@ pub fn refresh_remote_and_realign( return Ok(false); } - let remote = fresh_record.remote.as_deref().unwrap_or("origin"); + let Some(remote) = git_ops::remote_name(&fresh_record.remote) else { + return Ok(false); + }; let target_ref = format!("{remote}/{target_branch}"); git_ops::reset_current_branch_hard(&workspace_dir, &target_ref)?; Ok(true) @@ -411,12 +429,18 @@ pub fn prefetch_remote_refs( if !workspace_dir.is_dir() { return Ok(PrefetchRemoteRefsResponse { fetched: false }); } - let remote = record.remote.unwrap_or_else(|| "origin".to_string()); - git_ops::fetch_all_remote(&workspace_dir, &remote)?; + // Local-only repos have no remote to prefetch. + let Some(remote) = git_ops::remote_name(&record.remote) else { + return Ok(PrefetchRemoteRefsResponse { fetched: false }); + }; + git_ops::fetch_all_remote(&workspace_dir, remote)?; } else { let ctx = resolve_repo_context(None, repo_id)?; git_ops::ensure_git_repository(&ctx.root)?; - git_ops::fetch_all_remote(&ctx.root, &ctx.remote)?; + let Some(remote) = ctx.remote.as_deref() else { + return Ok(PrefetchRemoteRefsResponse { fetched: false }); + }; + git_ops::fetch_all_remote(&ctx.root, remote)?; } Ok(PrefetchRemoteRefsResponse { fetched: true }) @@ -440,10 +464,9 @@ pub fn sync_workspace_with_target_branch( .clone() .or(record.default_branch.clone()) .unwrap_or_else(|| "main".to_string()); - let remote = record - .remote - .clone() - .unwrap_or_else(|| "origin".to_string()); + let Some(remote) = git_ops::remote_name(&record.remote).map(str::to_owned) else { + bail!("This repository has no remote; remote operations are unavailable."); + }; let workspace_dir = helpers::workspace_path(&record)?; if !workspace_dir.is_dir() { bail_coded!( @@ -562,10 +585,9 @@ pub fn push_workspace_to_remote(workspace_id: &str) -> Result Option<(String, String)> { +) -> Option<(Option, String)> { let sql = format!( "SELECT r.remote, COALESCE(w.intended_target_branch, r.default_branch) FROM workspaces w @@ -329,14 +329,14 @@ pub(super) fn query_workspace_target( Ok((remote, target)) }) .ok() - .and_then(|(remote, target)| Some((remote.unwrap_or_else(|| "origin".into()), target?))) + .and_then(|(remote, target)| Some((remote, target?))) } pub(super) fn query_workspace_target_by_id( conn: &Connection, workspace_id: &str, workspace_root: &Path, -) -> Option<(String, String)> { +) -> Option<(Option, String)> { let sql = format!( "SELECT r.remote, COALESCE(w.intended_target_branch, r.default_branch), @@ -383,13 +383,13 @@ pub(super) fn query_workspace_target_by_id( return None; } - Some((remote.unwrap_or_else(|| "origin".into()), target?)) + Some((remote, target?)) } pub(super) fn query_local_workspace_target( conn: &Connection, workspace_root: &Path, -) -> Option<(String, String)> { +) -> Option<(Option, String)> { let root_path = workspace_root.to_string_lossy(); query_local_workspace_target_by_root_path(conn, root_path.as_ref()).or_else(|| { let canonical = workspace_root.canonicalize().ok()?; @@ -404,7 +404,7 @@ pub(super) fn query_local_workspace_target( fn query_local_workspace_target_by_root_path( conn: &Connection, root_path: &str, -) -> Option<(String, String)> { +) -> Option<(Option, String)> { let sql = format!( "SELECT r.remote, COALESCE(w.intended_target_branch, r.default_branch) FROM workspaces w @@ -424,7 +424,7 @@ fn query_local_workspace_target_by_root_path( return None; } - Some((remote.unwrap_or_else(|| "origin".into()), target?)) + Some((remote, target?)) } fn path_matches(path: &Path, stored: &str) -> bool { @@ -444,7 +444,7 @@ fn path_matches(path: &Path, stored: &str) -> bool { fn lookup_workspace_target( workspace_root: &Path, workspace_id: Option<&str>, -) -> Option<(String, String)> { +) -> Option<(Option, String)> { let conn = db::read_conn().ok()?; if let Some(workspace_id) = workspace_id { return query_workspace_target_by_id(&conn, workspace_id, workspace_root); @@ -471,7 +471,9 @@ pub(super) fn resolve_target_ref_for_workspace( let mut candidates = Vec::::new(); if let Some((remote, target)) = lookup_workspace_target(workspace_root, workspace_id) { - candidates.push(format!("refs/remotes/{remote}/{target}")); + if let Some(remote) = remote.as_deref() { + candidates.push(format!("refs/remotes/{remote}/{target}")); + } candidates.push(format!("refs/heads/{target}")); } diff --git a/src-tauri/src/workspace/files/tests/workspace_targets.rs b/src-tauri/src/workspace/files/tests/workspace_targets.rs index fb36b230b..10fb45748 100644 --- a/src-tauri/src/workspace/files/tests/workspace_targets.rs +++ b/src-tauri/src/workspace/files/tests/workspace_targets.rs @@ -32,28 +32,38 @@ fn parse_workspace_path_single_component_returns_none() { fn query_target_returns_intended_target_branch() { let conn = test_db_with_workspace(Some("origin"), Some("develop"), "main"); let result = query_workspace_target(&conn, "test-repo", "ws-dir"); - assert_eq!(result, Some(("origin".into(), "develop".into()))); + assert_eq!( + result, + Some((Some("origin".to_string()), "develop".to_string())) + ); } #[test] fn query_target_falls_back_to_default_branch() { let conn = test_db_with_workspace(Some("origin"), None, "main"); let result = query_workspace_target(&conn, "test-repo", "ws-dir"); - assert_eq!(result, Some(("origin".into(), "main".into()))); + assert_eq!( + result, + Some((Some("origin".to_string()), "main".to_string())) + ); } #[test] -fn query_target_defaults_remote_to_origin() { +fn query_target_preserves_absent_remote() { let conn = test_db_with_workspace(None, Some("develop"), "main"); let result = query_workspace_target(&conn, "test-repo", "ws-dir"); - assert_eq!(result, Some(("origin".into(), "develop".into()))); + // No remote configured → remote stays None (no fake "origin" default). + assert_eq!(result, Some((None, "develop".to_string()))); } #[test] fn query_target_custom_remote() { let conn = test_db_with_workspace(Some("upstream"), Some("release"), "main"); let result = query_workspace_target(&conn, "test-repo", "ws-dir"); - assert_eq!(result, Some(("upstream".into(), "release".into()))); + assert_eq!( + result, + Some((Some("upstream".to_string()), "release".to_string())) + ); } #[test] @@ -102,7 +112,10 @@ fn query_local_target_matches_repo_root_path() { .unwrap(); let result = query_local_workspace_target(&conn, repo_root.path()); - assert_eq!(result, Some(("origin".into(), "dev_ov21".into()))); + assert_eq!( + result, + Some((Some("origin".to_string()), "dev_ov21".to_string())) + ); } #[test] @@ -172,7 +185,10 @@ fn query_target_by_id_disambiguates_local_workspaces_on_same_repo_root() { } let result = query_workspace_target_by_id(&conn, "w2", repo_root.path()); - assert_eq!(result, Some(("origin".into(), "dev_alt".into()))); + assert_eq!( + result, + Some((Some("origin".to_string()), "dev_alt".to_string())) + ); } #[test] diff --git a/src-tauri/src/workspace/lifecycle.rs b/src-tauri/src/workspace/lifecycle.rs index 6302f8c4e..02a3a7f8f 100644 --- a/src-tauri/src/workspace/lifecycle.rs +++ b/src-tauri/src/workspace/lifecycle.rs @@ -154,16 +154,17 @@ pub fn prepare_workspace_from_repo_impl( let repo_root = PathBuf::from(repository.root_path.trim()); git_ops::ensure_git_repository(&repo_root)?; - let remote = repository - .remote - .clone() - .unwrap_or_else(|| "origin".to_string()); - - if !git_ops::has_remote(&repo_root, &remote)? { - bail!( - "Repository \"{}\" has no remote \"{remote}\". Workspaces require a remote to branch from.", - repository.name - ); + // Worktrees branch from a local branch (and, when present, the remote + // branch). A repo with no remote is fine — it just forks locally. When a + // remote IS configured, sanity-check that the git repo actually has it. + let remote = git_ops::remote_name(&repository.remote).map(str::to_owned); + if let Some(remote) = remote.as_deref() { + if !git_ops::has_remote(&repo_root, remote)? { + bail!( + "Repository \"{}\" has no remote \"{remote}\".", + repository.name + ); + } } let directory_name = helpers::allocate_directory_name_for_repo(repo_id)?; @@ -201,7 +202,7 @@ pub fn prepare_workspace_from_repo_impl( ); } - preflight_use_branch(&repo_root, &remote, &picked)?; + preflight_use_branch(&repo_root, remote.as_deref(), &picked)?; // TODO: when reusing a branch with a known upstream (e.g. `develop`), // derive `intended_target_branch` from `git rev-parse --symbolic-full-name @@ -270,15 +271,19 @@ pub fn prepare_workspace_from_repo_impl( /// cache, so a branch that exists upstream but was pushed since the last fetch /// will be misreported as `BranchNotFound`. Either pre-fetch the picked branch /// here, or surface a "couldn't verify — try fetch" hint in the UI. -fn preflight_use_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<()> { +fn preflight_use_branch(repo_root: &Path, remote: Option<&str>, branch: &str) -> Result<()> { let local_exists = git_ops::verify_branch_exists(repo_root, branch).is_ok(); - let remote_exists = - git_ops::verify_remote_ref_exists(repo_root, remote, branch).unwrap_or(false); + let remote_exists = remote + .map(|remote| git_ops::verify_remote_ref_exists(repo_root, remote, branch).unwrap_or(false)) + .unwrap_or(false); if !local_exists && !remote_exists { - return Err(coded(ErrorCode::BranchNotFound).context(format!( - "Branch `{branch}` not found locally or on remote `{remote}`." - ))); + let where_ = match remote { + Some(remote) => format!("locally or on remote `{remote}`"), + None => "locally".to_string(), + }; + return Err(coded(ErrorCode::BranchNotFound) + .context(format!("Branch `{branch}` not found {where_}."))); } if local_exists { @@ -567,10 +572,8 @@ pub fn finalize_workspace_from_repo_impl(workspace_id: &str) -> Result Result remote_ref, + None => { + git_ops::verify_branch_exists(&repo_root, &base_branch).with_context( + || format!("Base branch is missing in source repo: {base_branch}"), + )?; + base_branch.clone() + } }; git_ops::create_worktree_from_start_point( &repo_root, @@ -1378,7 +1386,8 @@ struct RestorePreflightData { branch: String, archive_commit: Option, target_branch: String, - remote: String, + /// `None` for local-only repos — restore is best-effort on the remote. + remote: Option, workspace_dir: PathBuf, } @@ -1404,7 +1413,7 @@ fn restore_workspace_preflight(workspace_id: &str) -> Result Result { + let has_any_refs = !git_ops::list_remote_branches(&preflight.repo_root, &remote) + .unwrap_or_default() + .is_empty(); + + let exists = git_ops::verify_remote_ref_exists(&preflight.repo_root, &remote, &target) + .unwrap_or(false); + + if exists || !has_any_refs { + None + } else { + let repo = crate::repos::load_repository_by_id(&record.repo_id)? + .with_context(|| format!("Repository not found: {}", record.repo_id))?; + let suggested = repo.default_branch.unwrap_or_else(|| "main".to_string()); + Some(TargetBranchConflict { + current_branch: target, + suggested_branch: suggested, + remote, + }) + } } - } else { - None + _ => None, }; Ok(ValidateRestoreResponse { @@ -1553,7 +1565,7 @@ pub fn restore_workspace_impl( (commit.to_string(), None) } None => ( - resolve_restore_target_start_point(&repo_root, &remote, target_branch)?, + resolve_restore_target_start_point(&repo_root, remote.as_deref(), target_branch)?, Some(target_branch.to_string()), ), }; @@ -1624,15 +1636,17 @@ pub fn restore_workspace_impl( fn resolve_restore_target_start_point( repo_root: &Path, - remote: &str, + remote: Option<&str>, target_branch: &str, ) -> Result { if git_ops::verify_branch_exists(repo_root, target_branch).is_ok() { return Ok(target_branch.to_string()); } - if git_ops::verify_remote_ref_exists(repo_root, remote, target_branch)? { - return Ok(format!("{remote}/{target_branch}")); + if let Some(remote) = remote { + if git_ops::verify_remote_ref_exists(repo_root, remote, target_branch)? { + return Ok(format!("{remote}/{target_branch}")); + } } bail!( diff --git a/src/features/inspector/index.tsx b/src/features/inspector/index.tsx index 19eff45d1..1a66badf6 100644 --- a/src/features/inspector/index.tsx +++ b/src/features/inspector/index.tsx @@ -536,6 +536,7 @@ export function WorkspaceInspectorSidebar({ workspaceId={workspaceId ?? null} workspaceRootPath={workspaceRootPath ?? null} workspaceBranch={workspaceBranch ?? null} + workspaceRemote={workspaceRemote ?? null} workspaceRemoteUrl={workspaceRemoteUrl ?? null} workspaceTargetBranch={workspaceTargetBranch ?? null} changes={changes} diff --git a/src/features/inspector/sections/actions.tsx b/src/features/inspector/sections/actions.tsx index 6c7ae4af6..be8fa4d86 100644 --- a/src/features/inspector/sections/actions.tsx +++ b/src/features/inspector/sections/actions.tsx @@ -74,6 +74,7 @@ function loadingActionLabel(label: string): string { return "pulling"; case "resolve": return "resolving"; + case "commit": case "commitPush": return "committing"; default: @@ -638,40 +639,45 @@ function buildGitRows( }), status: "pending", action: { - label: "commitPush", + // No remote → commit only (the prompt skips push too). + label: workspaceRemote ? "commitPush" : "commit", kind: "commit", mode: "commit-and-push", }, }, - gitStatus.pushStatus === "unpublished" - ? { - label: translateSource("branchNotPublishedRemote"), - status: "pending", - action: { - label: "push", - kind: "commit", - mode: "push", - }, - } - : (gitStatus.aheadOfRemoteCount ?? 0) > 0 - ? { - label: formatSource("inspectorCommitsAheadOf", { - count: gitStatus.aheadOfRemoteCount, - commitLabel: - gitStatus.aheadOfRemoteCount === 1 ? "commit" : "commits", - target: gitStatus.remoteTrackingRef ?? "upstream", - }), - status: "pending", - action: { - label: "push", - kind: "commit", - mode: "push", - }, - } - : { - label: translateSource("branchFullyPushed"), - status: "success", - }, + ...(workspaceRemote + ? ([ + gitStatus.pushStatus === "unpublished" + ? { + label: translateSource("branchNotPublishedRemote"), + status: "pending", + action: { + label: "push", + kind: "commit", + mode: "push", + }, + } + : (gitStatus.aheadOfRemoteCount ?? 0) > 0 + ? { + label: formatSource("inspectorCommitsAheadOf", { + count: gitStatus.aheadOfRemoteCount, + commitLabel: + gitStatus.aheadOfRemoteCount === 1 ? "commit" : "commits", + target: gitStatus.remoteTrackingRef ?? "upstream", + }), + status: "pending", + action: { + label: "push", + kind: "commit", + mode: "push", + }, + } + : { + label: translateSource("branchFullyPushed"), + status: "success", + }, + ] as GitStatusItem[]) + : []), conflictCount > 0 ? { label: translateSource("mergeConflictsDetected"), diff --git a/src/features/inspector/sections/changes.tsx b/src/features/inspector/sections/changes.tsx index 3b58e9d6e..95b90f69b 100644 --- a/src/features/inspector/sections/changes.tsx +++ b/src/features/inspector/sections/changes.tsx @@ -123,6 +123,9 @@ type ChangesSectionProps = { workspaceId: string | null; workspaceRootPath: string | null; workspaceBranch: string | null; + /** Remote NAME (e.g. "origin"); null for local-only repos. SSOT for + * "has a remote" — used to gate the ship/PR button. */ + workspaceRemote: string | null; workspaceRemoteUrl: string | null; workspaceTargetBranch: string | null; changes: InspectorFileItem[]; @@ -148,6 +151,7 @@ function ChangesSectionImpl({ workspaceId, workspaceRootPath, workspaceBranch, + workspaceRemote, workspaceRemoteUrl, workspaceTargetBranch, changes, @@ -344,6 +348,7 @@ function ChangesSectionImpl({ changeRequestName={changeRequestName} forgeRemoteState={forgeStatusQuery.data?.remoteState ?? null} forgeDetection={forgeDetection} + hasRemote={Boolean(workspaceRemote)} workspaceId={workspaceId} hasChanges={hasChanges} isRefreshing={isForgeRefreshing} diff --git a/src/features/inspector/sections/git-section-header.tsx b/src/features/inspector/sections/git-section-header.tsx index e93811388..a9d6a0983 100644 --- a/src/features/inspector/sections/git-section-header.tsx +++ b/src/features/inspector/sections/git-section-header.tsx @@ -92,6 +92,9 @@ export type GitSectionHeaderProps = { * needs attention, we swap the Create PR button for one forge connect CTA. */ forgeDetection?: ForgeDetection | null; + /** `false` for local-only repos (no remote): the ship/PR button and the + * forge Connect CTA are hidden — there's nowhere to push or open a PR. */ + hasRemote?: boolean; workspaceId?: string | null; onChangeRequestClick?: () => void; onCommit?: () => void | Promise; @@ -110,6 +113,7 @@ export function GitSectionHeader({ changeRequestName = "PR", forgeRemoteState = null, forgeDetection = null, + hasRemote = true, workspaceId = null, onChangeRequestClick, onCommit, @@ -156,17 +160,21 @@ export function GitSectionHeader({ // `cliStatus.status === "unauthenticated"` clause was redundant // global-state plumbing. const showForgeOnboarding = - forgeRemoteState === "unauthenticated" && forgeDetection !== null; + hasRemote && + forgeRemoteState === "unauthenticated" && + forgeDetection !== null; useEffect(() => { if (!showForgeOnboarding) { setForgeConnecting(false); } }, [showForgeOnboarding]); + // Local-only repos (no remote) have no ship/PR/push action at all. const showButton = - hasChanges || - commitButtonState === "busy" || - commitButtonMode !== "create-pr" || - showForgeOnboarding; + hasRemote && + (hasChanges || + commitButtonState === "busy" || + commitButtonMode !== "create-pr" || + showForgeOnboarding); const isMergeRequest = forgeDetection?.provider === "gitlab"; const showChangeRequest = changeRequest !== null && !showForgeOnboarding; const showContinue = commitButtonMode === "merged" && showChangeRequest; diff --git a/src/features/panel/header.tsx b/src/features/panel/header.tsx index 40e36a259..d74f125c8 100644 --- a/src/features/panel/header.tsx +++ b/src/features/panel/header.tsx @@ -145,6 +145,9 @@ export const WorkspacePanelHeader = memo(function WorkspacePanelHeader({ newSessionShortcut, }: WorkspacePanelHeaderProps) { const { t, f } = useI18n(); + // Local-only repos (no remote) have no target branch / PR / stacked-PR + // concept — show only the current branch name, no `→ target` picker. + const hasRemote = Boolean(workspace?.remote); const branchTone = getWorkspaceBranchTone({ workspaceState: workspace?.state, status: workspace?.status, @@ -370,7 +373,7 @@ export const WorkspacePanelHeader = memo(function WorkspacePanelHeader({ )} - {workspace?.parentWorkspaceId ? ( + {hasRemote && workspace?.parentWorkspaceId ? ( - ) : workspace?.intendedTargetBranch ? ( + ) : hasRemote && workspace?.intendedTargetBranch ? ( <> ({ listRemoteBranches: vi.fn(), + listBranchesForLocalPicker: vi.fn(), listRepoRemotes: vi.fn(), listForgeAccounts: vi.fn(), loadRepoPreferences: vi.fn(), @@ -20,6 +21,7 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...actual, listRemoteBranches: apiMocks.listRemoteBranches, + listBranchesForLocalPicker: apiMocks.listBranchesForLocalPicker, listRepoRemotes: apiMocks.listRepoRemotes, listForgeAccounts: apiMocks.listForgeAccounts, loadRepoPreferences: apiMocks.loadRepoPreferences, @@ -69,6 +71,7 @@ describe("RepositorySettingsPanel branch prefix", () => { beforeEach(() => { vi.useFakeTimers(); apiMocks.listRemoteBranches.mockResolvedValue([]); + apiMocks.listBranchesForLocalPicker.mockResolvedValue([]); apiMocks.listRepoRemotes.mockResolvedValue([]); apiMocks.listForgeAccounts.mockResolvedValue([]); apiMocks.loadRepoPreferences.mockResolvedValue({ @@ -217,4 +220,27 @@ describe("RepositorySettingsPanel branch prefix", () => { expect(screen.getByText("General preferences")).toBeInTheDocument(); expect(screen.queryByText("Create PR preferences")).toBeNull(); }); + + it("shows a local-only notice and hides remote/account/PR config for a git repo without a remote", () => { + renderPanel( + repo({ + remote: null, + remoteUrl: null, + forgeProvider: null, + forgeLogin: null, + // Still a git repo (has a default branch). + defaultBranch: "main", + }), + ); + + expect(screen.getByText("Local-only repository")).toBeInTheDocument(); + // No account/connect, no remote-origin config, no PR-class prefs. + expect(screen.queryByRole("button", { name: /^Connect$/i })).toBeNull(); + expect(screen.queryByText("GitHub not connected")).toBeNull(); + expect(screen.queryByText("Remote origin")).toBeNull(); + expect(screen.queryByText("Create PR preferences")).toBeNull(); + // But the branch picker (local) and general preferences DO apply. + expect(screen.getByText("Branch new workspaces from")).toBeInTheDocument(); + expect(screen.getByText("General preferences")).toBeInTheDocument(); + }); }); diff --git a/src/features/settings/panels/repository-settings.tsx b/src/features/settings/panels/repository-settings.tsx index 1beefbf38..5f842619a 100644 --- a/src/features/settings/panels/repository-settings.tsx +++ b/src/features/settings/panels/repository-settings.tsx @@ -25,6 +25,7 @@ import { import { type ForgeAccount, type ForgeProvider, + listBranchesForLocalPicker, listRemoteBranches, listRepoRemotes, prefetchRemoteRefs, @@ -57,9 +58,13 @@ export function RepositorySettingsPanel({ // The bound gh/glab account login lives on the repo row now; // no more global OAuth identity. const { f } = useI18n(); - // A repo added as a plain folder has no default branch — git config - // (remote, branch, prefix) and forge accounts don't apply to it. + // Three states: + // - non-git (no default branch): no git config at all. + // - no-remote (git, no remote): branch + scripts apply, but no + // remote/account/PR/branch-prefix. + // - normal (git + remote): the full panel. const isNonGit = !repo.defaultBranch; + const hasRemote = Boolean(repo.remote); const githubLogin = repo.forgeLogin ?? null; const [branches, setBranches] = useState([]); const [loading, setLoading] = useState(false); @@ -86,19 +91,23 @@ export function RepositorySettingsPanel({ const fetchBranches = useCallback(() => { setLoading(true); - void listRemoteBranches({ repoId: repo.id }) - .then(setBranches) - .finally(() => setLoading(false)); - }, [repo.id]); + // No remote → list local branches; otherwise the remote refs. + const load = hasRemote + ? listRemoteBranches({ repoId: repo.id }) + : listBranchesForLocalPicker(repo.id); + void load.then(setBranches).finally(() => setLoading(false)); + }, [repo.id, hasRemote]); const handleOpen = useCallback(() => { fetchBranches(); + // Prefetch only makes sense with a remote. + if (!hasRemote) return; void prefetchRemoteRefs({ repoId: repo.id }) .then(({ fetched }) => { if (fetched) fetchBranches(); }) .catch(() => {}); - }, [repo.id, fetchBranches]); + }, [repo.id, fetchBranches, hasRemote]); const handleSelect = useCallback( (branch: string) => { @@ -155,118 +164,122 @@ export function RepositorySettingsPanel({ - {!isNonGit && ( - <> -
-
- -
-
- -
-
- { - setRemoteOpen(next); - if (next) fetchRemotes(); - }} - > - - {currentRemote} - - - - - - - - - {remotes.map((remote) => ( - handleRemoteSelect(remote)} - className="flex items-center justify-between gap-2 px-1.5 py-1 text-small" - > - - {remote} - - {remote === currentRemote && ( - + {!isNonGit && hasRemote && ( +
+
+ +
+
+ +
+
+ { + setRemoteOpen(next); + if (next) fetchRemotes(); + }} + > + + {currentRemote} + + + + + + + + + {remotes.map((remote) => ( + handleRemoteSelect(remote)} + className="flex items-center justify-between gap-2 px-1.5 py-1 text-small" + > + - ))} - - - - - {remoteError && ( -

{remoteError}

- )} - {remoteNotice && ( -

- {remoteNotice} -

- )} -
+ > + {remote} + + {remote === currentRemote && ( + + )} + + ))} + + + + + {remoteError && ( +

{remoteError}

+ )} + {remoteNotice && ( +

+ {remoteNotice} +

+ )}
+
+ )} -
-
- -
-
- -
-
- +
+ +
+
+ +
+
+ + - - {error && ( -

{error}

- )} -
+ + + {repo.remote + ? `${repo.remote}/${currentBranch}` + : currentBranch} + + + +
+ {error && ( +

{error}

+ )}
+
+ )} - - + {!isNonGit && hasRemote && ( + )} {!isNonGit && ( @@ -274,7 +287,10 @@ export function RepositorySettingsPanel({
)} - +
@@ -359,6 +375,29 @@ function ForgeAccountHeader({ ); } + // Git repo with no remote: branch/diff/commit work, but there's no + // account, push, pull, or pull-request flow — say so instead of a CTA. + if (!repo.remote) { + return ( +
+
+
+ + + + +
+
+ +
+
+
+ ); + } + if (!effectiveLogin) { return (
diff --git a/src/lib/commit-button-prompts.test.ts b/src/lib/commit-button-prompts.test.ts index b49649ec9..41cb0a9b2 100644 --- a/src/lib/commit-button-prompts.test.ts +++ b/src/lib/commit-button-prompts.test.ts @@ -169,12 +169,14 @@ describe("buildCommitButtonPrompt", () => { null, null, GITHUB_FORGE, + "origin", ); const gitlabPrompt = buildCommitButtonPrompt( "commit-and-push", null, null, GITLAB_FORGE, + "origin", ); expect(githubPrompt).toBe(gitlabPrompt); expect(githubPrompt).toContain("Commit and push all uncommitted work"); @@ -193,9 +195,11 @@ describe("buildCommitButtonPrompt", () => { expect(prompt).not.toContain(""); }); - it("falls back to `origin` when the workspace remote is missing", () => { + it("emits a commit-only prompt (no push) when the workspace has no remote", () => { const prompt = buildCommitButtonPrompt("commit-and-push", null, null); - expect(prompt).toContain("`git push -u origin HEAD`"); + expect(prompt).toContain("Commit all uncommitted work"); + expect(prompt).toContain("This repository has no remote"); + expect(prompt).not.toContain("git push"); expect(prompt).not.toContain(""); }); diff --git a/src/lib/commit-button-prompts.ts b/src/lib/commit-button-prompts.ts index 299905c67..e5e486d3a 100644 --- a/src/lib/commit-button-prompts.ts +++ b/src/lib/commit-button-prompts.ts @@ -41,8 +41,21 @@ export function buildCommitButtonPrompt( const remoteName = remote && remote.trim().length > 0 ? remote.trim() : "origin"; switch (mode) { - case "commit-and-push": + case "commit-and-push": { // Pure git — no forge involved. + const hasRemote = Boolean(remote && remote.trim().length > 0); + // Local-only repo (no remote): commit, nothing to push to. + if (!hasRemote) { + return `Commit all uncommitted work in this workspace. + +Do the following, in order: +1. Run \`git status\` and \`git diff\` to survey what's changed. +2. Stage everything that should ship with \`git add\`. +3. Commit with a concise, Conventional-Commits-style message (\`feat:\`, \`fix:\`, \`refactor:\`, etc.) summarizing the change. +4. Report the resulting commit SHA. + +This repository has no remote, so do NOT push. Don't stop to ask for confirmation — execute each step automatically. If a pre-commit hook fails, report the failure and stop.`; + } return `Commit and push all uncommitted work in this workspace. Do the following, in order: @@ -53,6 +66,7 @@ Do the following, in order: 5. Report the resulting commit SHA and pushed ref. Don't stop to ask for confirmation — execute each step automatically. If a pre-commit / pre-push hook fails, report the failure and stop without force-pushing.`; + } case "open-pr": { const dialect = forgePromptDialect(forge); diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index f3127858f..d05b753c8 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -266,6 +266,7 @@ "commitMergeabilityCalculating": "Mergeability is still being calculated. Please wait and try again.", "commitPromptNotDelivered": "Prompt not delivered", "commitProviderNotConnected": "{provider} not connected", + "commit": "Commit", "commitPush": "Commit and push", "commitPushManually": "Commit and push manually", "commitReconnectAccountTryAgain": "Reconnect your {provider} account and try again.", @@ -784,6 +785,8 @@ "loadingUpdaterStatus": "Loading updater status…", "localLlm": "Local LLM", "localLlmLandsVeryExperimentalPreview": "Local LLM lands as a very experimental preview — it powers session title and branch name generation for now. More features coming soon.", + "localOnlyRepository": "Local-only repository", + "localOnlyRepositoryDescription": "This git repository has no remote — branch, diff, and commit work, but push, pull, and pull requests are unavailable.", "localRepositoryStaysExactlyBranchFiles": "Your local repository stays exactly as it is — branch and files untouched.", "log": "Log in", "logAgents": "Log in to your agents", diff --git a/src/lib/i18n/locales/zh-CN.json b/src/lib/i18n/locales/zh-CN.json index f81c1570c..aa23ef2d8 100644 --- a/src/lib/i18n/locales/zh-CN.json +++ b/src/lib/i18n/locales/zh-CN.json @@ -266,6 +266,7 @@ "commitMergeabilityCalculating": "仍在计算可合并性,请稍候重试。", "commitPromptNotDelivered": "提示词未送达", "commitProviderNotConnected": "{provider} 未连接", + "commit": "提交", "commitPush": "提交并推送", "commitPushManually": "手动提交并推送", "commitReconnectAccountTryAgain": "请重新连接你的 {provider} 账户后重试。", @@ -784,6 +785,8 @@ "loadingUpdaterStatus": "正在加载更新器状态…", "localLlm": "本地 LLM", "localLlmLandsVeryExperimentalPreview": "本地 LLM 以非常实验性的预览形式上线,目前用于生成会话标题和分支名。更多功能即将推出。", + "localOnlyRepository": "纯本地仓库(无远端)", + "localOnlyRepositoryDescription": "这个 Git 仓库没有配置远端——分支、diff、提交都可用,但无法 push、pull 或创建 PR。", "localRepositoryStaysExactlyBranchFiles": "你的本地仓库会保持原样,分支和文件都不会被修改。", "log": "登录", "logAgents": "登录你的 agent",