From 592d5852d5c21e59ae2c0decc028b42cd7e2ab8b Mon Sep 17 00:00:00 2001 From: Nathan L Date: Wed, 24 Jun 2026 17:56:05 +0800 Subject: [PATCH 1/4] feat: support local git repositories without a remote Accept git repos that have no remote (local-only), including worktree workspaces. Branches, diffs, commits, and worktrees all work; the default branch comes from local HEAD. - Unify remote handling: add `git_ops::remote_name()` as the single source of truth and remove all `unwrap_or("origin")` debt; restore is now best-effort on the remote (and works without one). - Repo creation no longer rejects no-remote git repos; forge_provider stays None when there's no remote. - Worktree creation forks from a local branch when there's no remote. - Gate push / pull / fetch / PR / Connect UI for local-only repos; repo settings becomes three-state (normal / no-remote / non-git) with a local-branch picker and a "Local-only repository" notice. --- .announcements/git-no-remote.json | 7 + .changeset/git-no-remote-support.md | 8 + src-tauri/src/commands/tests/repository.rs | 29 +- src-tauri/src/commands/tests/support.rs | 12 +- .../src/commands/tests/workspace_creation.rs | 63 ++++- src-tauri/src/commands/workspace_commands.rs | 14 +- src-tauri/src/git/ops.rs | 12 + src-tauri/src/models/repos.rs | 43 ++- src-tauri/src/workspace/branching.rs | 71 +++-- src-tauri/src/workspace/files/changes.rs | 20 +- .../files/tests/workspace_targets.rs | 30 +- src-tauri/src/workspace/lifecycle.rs | 140 ++++----- src/features/inspector/sections/changes.tsx | 1 + .../inspector/sections/git-section-header.tsx | 18 +- .../panels/repository-settings.test.tsx | 26 ++ .../settings/panels/repository-settings.tsx | 267 ++++++++++-------- src/lib/i18n/locales/en.json | 2 + src/lib/i18n/locales/zh-CN.json | 2 + 18 files changed, 515 insertions(+), 250 deletions(-) create mode 100644 .announcements/git-no-remote.json create mode 100644 .changeset/git-no-remote-support.md diff --git a/.announcements/git-no-remote.json b/.announcements/git-no-remote.json new file mode 100644 index 000000000..189096a9b --- /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 — branches, diffs, and worktrees all work. Push and pull-request actions are hidden since there's nowhere to publish." + } + ] +} 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..0d941892d 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/sections/changes.tsx b/src/features/inspector/sections/changes.tsx index 3b58e9d6e..a1b4a46b6 100644 --- a/src/features/inspector/sections/changes.tsx +++ b/src/features/inspector/sections/changes.tsx @@ -344,6 +344,7 @@ function ChangesSectionImpl({ changeRequestName={changeRequestName} forgeRemoteState={forgeStatusQuery.data?.remoteState ?? null} forgeDetection={forgeDetection} + hasRemote={Boolean(workspaceRemoteUrl)} 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/settings/panels/repository-settings.test.tsx b/src/features/settings/panels/repository-settings.test.tsx index 1d80bcb33..974d059b4 100644 --- a/src/features/settings/panels/repository-settings.test.tsx +++ b/src/features/settings/panels/repository-settings.test.tsx @@ -7,6 +7,7 @@ import { RepositorySettingsPanel } from "./repository-settings"; const apiMocks = vi.hoisted(() => ({ 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/i18n/locales/en.json b/src/lib/i18n/locales/en.json index f3127858f..bc89bed94 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -784,6 +784,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..a50b4eb6c 100644 --- a/src/lib/i18n/locales/zh-CN.json +++ b/src/lib/i18n/locales/zh-CN.json @@ -784,6 +784,8 @@ "loadingUpdaterStatus": "正在加载更新器状态…", "localLlm": "本地 LLM", "localLlmLandsVeryExperimentalPreview": "本地 LLM 以非常实验性的预览形式上线,目前用于生成会话标题和分支名。更多功能即将推出。", + "localOnlyRepository": "纯本地仓库(无远端)", + "localOnlyRepositoryDescription": "这个 Git 仓库没有配置远端——分支、diff、提交都可用,但无法 push、pull 或创建 PR。", "localRepositoryStaysExactlyBranchFiles": "你的本地仓库会保持原样,分支和文件都不会被修改。", "log": "登录", "logAgents": "登录你的 agent", From cc18cb78f3a45e8c25eddabe2c7b156dd226e503 Mon Sep 17 00:00:00 2001 From: Nathan L Date: Thu, 25 Jun 2026 02:49:57 +0800 Subject: [PATCH 2/4] chore: simplify git-no-remote announcement text --- .announcements/git-no-remote.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.announcements/git-no-remote.json b/.announcements/git-no-remote.json index 189096a9b..b679d01ef 100644 --- a/.announcements/git-no-remote.json +++ b/.announcements/git-no-remote.json @@ -1,7 +1,7 @@ { "items": [ { - "text": "Open project now works on local git repositories with no remote — branches, diffs, and worktrees all work. Push and pull-request actions are hidden since there's nowhere to publish." + "text": "Open project now works on local git repositories with no remote." } ] } From 0825c435ebe9734405da9f8a60184ac7b743234a Mon Sep 17 00:00:00 2001 From: Nathan L Date: Thu, 25 Jun 2026 03:18:51 +0800 Subject: [PATCH 3/4] fix: hide remote-only UI for local-only repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A git repo with no remote was still showing remote-only UI that makes no sense without a remote: - The inspector "Actions → Git" section dropped the push-status row ("Branch fully pushed" / "Branch not published"); only uncommitted and sync (shown as "unavailable") remain. - The workspace header no longer renders the `→ target branch` picker or the stacked-PR chip — it shows just the current branch name. --- src/features/inspector/sections/actions.tsx | 62 +++++++++++---------- src/features/panel/header.tsx | 7 ++- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/features/inspector/sections/actions.tsx b/src/features/inspector/sections/actions.tsx index 6c7ae4af6..4f50d2b3d 100644 --- a/src/features/inspector/sections/actions.tsx +++ b/src/features/inspector/sections/actions.tsx @@ -643,35 +643,39 @@ function buildGitRows( 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/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 ? ( <> Date: Thu, 25 Jun 2026 03:36:53 +0800 Subject: [PATCH 4/4] fix: address no-remote review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inspector "uncommitted changes" action commits locally only when the repo has no remote. It was hardcoded to commit-and-push, so the dispatched prompt told the agent to `git push -u origin HEAD` — which always failed on a remoteless repo (and the header commit button is hidden there, so this was the only commit entry). The prompt now skips push and the row reads "Commit" instead of "Commit and push" when there's no remote. - Changes-section `hasRemote` now keys off the remote name (the repo.remote SSOT) instead of remoteUrl, matching header/panel/settings. - Reword the default-branch resolution error: an empty `git init` repo resolves to its unborn branch and is accepted, so the old "empty repository" wording was misleading. --- src-tauri/src/models/repos.rs | 2 +- src/features/inspector/index.tsx | 1 + src/features/inspector/sections/actions.tsx | 4 +++- src/features/inspector/sections/changes.tsx | 6 +++++- src/lib/commit-button-prompts.test.ts | 8 ++++++-- src/lib/commit-button-prompts.ts | 16 +++++++++++++++- src/lib/i18n/locales/en.json | 1 + src/lib/i18n/locales/zh-CN.json | 1 + 8 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/models/repos.rs b/src-tauri/src/models/repos.rs index 0d941892d..04c2cdb1f 100644 --- a/src-tauri/src/models/repos.rs +++ b/src-tauri/src/models/repos.rs @@ -1398,7 +1398,7 @@ pub fn resolve_repository_from_local_path(folder_path: &str) -> Result { 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 bc89bed94..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.", diff --git a/src/lib/i18n/locales/zh-CN.json b/src/lib/i18n/locales/zh-CN.json index a50b4eb6c..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} 账户后重试。",