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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .announcements/git-no-remote.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"items": [
{
"text": "Open project now works on local git repositories with no remote."
}
]
}
8 changes: 8 additions & 0 deletions .changeset/git-no-remote-support.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 23 additions & 6 deletions src-tauri/src/commands/tests/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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]
Expand Down
12 changes: 9 additions & 3 deletions src-tauri/src/commands/tests/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,10 +716,14 @@ fn create_workspace_fixture_db(
// produces the `testuser/<directory>` 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();
Expand Down Expand Up @@ -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();
Expand Down
63 changes: 62 additions & 1 deletion src-tauri/src/commands/tests/workspace_creation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 9 additions & 5 deletions src-tauri/src/commands/workspace_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,13 @@ pub async fn list_branches_for_local_picker(repo_id: String) -> CmdResult<Vec<St
if !repo_root.is_dir() {
return Ok(Vec::new());
}
let remote = repo.remote.unwrap_or_else(|| "origin".to_string());

let mut seen = std::collections::BTreeSet::new();
seen.extend(crate::git_ops::list_local_branches(&repo_root).unwrap_or_default());
seen.extend(crate::git_ops::list_remote_branches(&repo_root, &remote).unwrap_or_default());
if let Some(remote) = crate::git_ops::remote_name(&repo.remote) {
seen.extend(
crate::git_ops::list_remote_branches(&repo_root, remote).unwrap_or_default(),
);
}
Ok(seen.into_iter().collect())
})
.await
Expand All @@ -226,8 +228,10 @@ pub async fn list_branches_for_workspace_picker(
if !repo_root.is_dir() {
return Ok(Vec::new());
}
let remote = repo.remote.unwrap_or_else(|| "origin".to_string());
Ok(workspaces::list_branch_picker_entries(&repo_root, &remote))
Ok(workspaces::list_branch_picker_entries(
&repo_root,
crate::git_ops::remote_name(&repo.remote),
))
},
)
.await
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/git/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,18 @@ pub fn default_branch_ref(remote: &str, default_branch: &str) -> 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<String>) -> Option<&str> {
remote
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}

pub fn tracked_file_count(workspace_dir: &Path) -> Result<i64> {
let workspace_dir = workspace_dir.display().to_string();
let output = run_git(["-C", workspace_dir.as_str(), "ls-files"], None).with_context(|| {
Expand Down
43 changes: 31 additions & 12 deletions src-tauri/src/models/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1386,28 +1386,30 @@ pub fn resolve_repository_from_local_path(folder_path: &str) -> Result<ResolvedR
)
})?;

// A git repo with no remote is supported (local-only). Its default
// branch comes from local HEAD; push/pull/PR/forge features are gated
// off downstream by the absent `remote`.
let remote = resolve_repository_remote(normalized_root)?;
if remote.is_none() {
bail!("Local-only repositories are not supported.");
}
let remote_url = match remote.as_deref() {
Some(remote_name) => Some(resolve_repository_remote_url(normalized_root, remote_name)?),
None => None,
};
let default_branch = resolve_repository_default_branch(normalized_root, remote.as_deref())
.with_context(|| {
format!(
"Unable to resolve a default branch for repository {}",
"Unable to resolve a default branch for repository {} \
(no remote HEAD and no local branch could be determined)",
normalized_root.display()
)
})?;

// Keep repo creation local: no network probes or CLI calls here.
let (provider, _) = crate::forge::detect_provider_for_repo_offline(
remote_url.as_deref(),
Some(normalized_root),
);
let forge_provider = Some(provider.as_storage_str().to_string());
// Forge classification only applies to repos with a remote. Keep repo
// creation local: no network probes or CLI calls here.
let forge_provider = remote_url.as_deref().map(|url| {
let (provider, _) =
crate::forge::detect_provider_for_repo_offline(Some(url), Some(normalized_root));
provider.as_storage_str().to_string()
});

Ok(ResolvedRepositoryInput {
name,
Expand Down Expand Up @@ -1712,8 +1714,25 @@ fn resolve_repository_remote_url(repo_root: &Path, remote: &str) -> Result<Strin
}

fn resolve_repository_default_branch(repo_root: &Path, remote: Option<&str>) -> Option<String> {
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<String> {
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<String> {
Expand Down
Loading
Loading