From 8f23e15c40fa81e6f8c463c862fa3ff360aae225 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 25 Jun 2026 14:16:46 +1000 Subject: [PATCH 1/4] fix: support offline repo fallback Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/branches.rs | 79 ++++++- apps/staged/src-tauri/src/git/github.rs | 204 ++++++++++++++++-- apps/staged/src-tauri/src/web_server.rs | 10 +- .../lib/features/projects/AddRepoModal.svelte | 8 +- .../features/projects/NewProjectForm.svelte | 8 +- .../features/projects/RepoConfigForm.svelte | 8 +- .../lib/features/projects/SubpathInput.svelte | 27 ++- 7 files changed, 295 insertions(+), 49 deletions(-) diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 68d0fff34..7a7330d8a 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -678,6 +678,40 @@ fn create_worktree_with_fallback( } } +fn local_base_ref_for_worktree(repo_path: &Path, base_branch: &str) -> Option { + let base_ref = git::origin_ref_for_branch(base_branch); + git::resolve_ref(repo_path, &base_ref).ok()?; + Some(base_ref) +} + +pub(crate) fn fetch_for_worktree_with_offline_fallback( + repo_path: &Path, + repo_slug: &str, + branch_name: &str, + base_branch: &str, +) -> Result<(), String> { + match git::fetch_for_worktree(repo_path, repo_slug, branch_name, base_branch) { + Ok(()) => Ok(()), + Err(fetch_err) => { + if let Some(base_ref) = local_base_ref_for_worktree(repo_path, base_branch) { + log::warn!( + "fetch for worktree branch '{}' in '{}' failed; using stale local ref '{}': {}", + branch_name, + repo_slug, + base_ref, + fetch_err + ); + Ok(()) + } else { + let base_ref = git::origin_ref_for_branch(base_branch); + Err(format!( + "GitHub is unavailable and the local clone for '{repo_slug}' does not have required base ref '{base_ref}': {fetch_err}" + )) + } + } + } +} + pub(crate) fn is_blox_onboarding_precondition_error(err: &blox::BloxError) -> bool { match err { blox::BloxError::CommandFailed(stderr) => { @@ -1137,13 +1171,12 @@ pub async fn setup_worktree( // Ensure we have a local clone, then fetch the specific refs we need. let repo_slug = resolve_branch_repo_slug(&store, &project, &branch)?; let repo_path = git::ensure_local_clone(&repo_slug).map_err(|e| e.to_string())?; - git::fetch_for_worktree( + fetch_for_worktree_with_offline_fallback( &repo_path, &repo_slug, &branch.branch_name, &branch.base_branch, - ) - .map_err(|e| e.to_string())?; + )?; let desired_worktree_path = git::project_worktree_path_for(&branch.project_id, &repo_slug, &branch.branch_name) .map_err(|e| e.to_string())?; @@ -2333,13 +2366,12 @@ pub(crate) fn setup_worktree_sync( crate::git::ensure_local_clone(&repo_slug).map_err(|e| e.to_string())? }; emit_progress("fetching", None); - crate::git::fetch_for_worktree( + fetch_for_worktree_with_offline_fallback( &repo_path, &repo_slug, &branch.branch_name, &branch.base_branch, - ) - .map_err(|e| e.to_string())?; + )?; let desired_worktree_path = crate::git::project_worktree_path_for(&branch.project_id, &repo_slug, &branch.branch_name) .map_err(|e| e.to_string())?; @@ -2540,3 +2572,38 @@ pub(crate) async fn run_prerun_actions_for_branch( Ok(count) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::TempGitRepo; + + #[test] + fn local_base_ref_for_worktree_accepts_existing_origin_ref() { + let repo = TempGitRepo::new(); + repo.write_file("README.md", "hello"); + repo.commit("initial"); + repo.run_git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); + + assert_eq!( + local_base_ref_for_worktree(repo.path(), "origin/main"), + Some("origin/main".to_string()) + ); + assert_eq!( + local_base_ref_for_worktree(repo.path(), "main"), + Some("origin/main".to_string()) + ); + } + + #[test] + fn local_base_ref_for_worktree_rejects_missing_origin_ref() { + let repo = TempGitRepo::new(); + repo.write_file("README.md", "hello"); + repo.commit("initial"); + + assert_eq!( + local_base_ref_for_worktree(repo.path(), "origin/main"), + None + ); + } +} diff --git a/apps/staged/src-tauri/src/git/github.rs b/apps/staged/src-tauri/src/git/github.rs index 444f8bb97..041a5adad 100644 --- a/apps/staged/src-tauri/src/git/github.rs +++ b/apps/staged/src-tauri/src/git/github.rs @@ -8,7 +8,7 @@ use super::DiffSpec; use super::GitRef; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::{Duration, Instant}; @@ -2617,16 +2617,121 @@ pub async fn update_pull_request( // Subpath Validation // ============================================================================= +fn invalid_repo_path_error() -> GitError { + GitError::CommandFailed("Invalid path in repo".to_string()) +} + +fn is_github_not_found_error(msg: &str) -> bool { + msg.contains("Not Found") || msg.contains("HTTP 404") +} + +fn normalize_repo_subpath(subpath: &str) -> Result, GitError> { + let trimmed = subpath.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.starts_with('/') || Path::new(trimmed).is_absolute() { + return Err(invalid_repo_path_error()); + } + + let trimmed = trimmed.trim_end_matches('/'); + if trimmed.is_empty() { + return Ok(None); + } + + let mut normalized = PathBuf::new(); + for segment in trimmed.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err(invalid_repo_path_error()); + } + normalized.push(segment); + } + + Ok(Some(normalized)) +} + +fn normalized_repo_subpath_string(subpath: &str) -> Result, GitError> { + let Some(relative_path) = normalize_repo_subpath(subpath)? else { + return Ok(None); + }; + Ok(Some( + relative_path + .iter() + .map(|segment| segment.to_string_lossy().into_owned()) + .collect::>() + .join("/"), + )) +} + +fn local_subpath_is_dir(clone_path: &Path, subpath: &str) -> Result { + let Some(relative_path) = normalize_repo_subpath(subpath)? else { + return Ok(true); + }; + Ok(clone_path.join(relative_path).is_dir()) +} + +fn local_repo_subpath_is_dir(github_repo: &str, subpath: &str) -> Result, GitError> { + let Some(clone_path) = crate::paths::clone_path_for(github_repo) else { + return Ok(None); + }; + if !clone_path.join(".git").exists() { + return Ok(None); + } + local_subpath_is_dir(&clone_path, subpath).map(Some) +} + +fn list_local_directories_at_clone(clone_path: &Path, path: &str) -> Result, GitError> { + let target = match normalize_repo_subpath(path)? { + Some(relative_path) => clone_path.join(relative_path), + None => clone_path.to_path_buf(), + }; + + if !target.is_dir() { + return Ok(vec![]); + } + + let entries = std::fs::read_dir(&target).map_err(|e| { + GitError::CommandFailed(format!( + "Failed to read local repo path '{}': {e}", + target.display() + )) + })?; + + let mut dirs = entries + .filter_map(Result::ok) + .filter_map(|entry| match entry.file_type() { + Ok(file_type) if file_type.is_dir() => entry.file_name().into_string().ok(), + _ => None, + }) + .collect::>(); + dirs.sort(); + Ok(dirs) +} + +fn list_local_repo_directories( + github_repo: &str, + path: &str, +) -> Result>, GitError> { + let Some(clone_path) = crate::paths::clone_path_for(github_repo) else { + return Ok(None); + }; + if !clone_path.join(".git").exists() { + return Ok(None); + } + list_local_directories_at_clone(&clone_path, path).map(Some) +} + /// Validate that a subpath exists as a directory in a GitHub repository. /// /// Uses the GitHub contents API to check that the path exists and is a -/// directory (the API returns an array for directories). Returns an error -/// if the path does not exist or points to a file rather than a directory. +/// directory (the API returns an array for directories). If GitHub cannot be +/// reached, falls back to the existing local clone when one is available. +/// Returns an error if the path does not exist or points to a file rather than +/// a directory. pub fn validate_subpath_in_repo(github_repo: &str, subpath: &str) -> Result<(), GitError> { - let trimmed = subpath.trim_matches('/'); - if trimmed.is_empty() { + let Some(trimmed) = normalized_repo_subpath_string(subpath)? else { return Ok(()); - } + }; let endpoint = format!("repos/{github_repo}/contents/{trimmed}"); match run_gh_global(&["api", &endpoint]) { @@ -2637,15 +2742,27 @@ pub fn validate_subpath_in_repo(github_repo: &str, subpath: &str) -> Result<(), if body.starts_with('[') { Ok(()) } else { - Err(GitError::CommandFailed("Invalid path in repo".to_string())) + Err(invalid_repo_path_error()) } } Err(e) => { let msg = e.to_string(); - if msg.contains("Not Found") || msg.contains("HTTP 404") { - Err(GitError::CommandFailed("Invalid path in repo".to_string())) - } else { - Err(e) + if is_github_not_found_error(&msg) { + return Err(invalid_repo_path_error()); + } + + match local_repo_subpath_is_dir(github_repo, &trimmed)? { + Some(true) => { + log::warn!( + "validated '{}' in '{}' from local clone after GitHub validation failed: {}", + trimmed, + github_repo, + msg + ); + Ok(()) + } + Some(false) => Err(invalid_repo_path_error()), + None => Err(e), } } } @@ -2653,9 +2770,11 @@ pub fn validate_subpath_in_repo(github_repo: &str, subpath: &str) -> Result<(), /// List directories at a given path in a GitHub repository. /// Returns a list of directory names (not files) at the specified path. -/// If `path` is empty, lists directories at the repository root. +/// If `path` is empty, lists directories at the repository root. If GitHub +/// cannot be reached, falls back to the existing local clone when one is +/// available. pub fn list_repo_directories(github_repo: &str, path: &str) -> Result, GitError> { - let trimmed = path.trim_matches('/'); + let trimmed = normalized_repo_subpath_string(path)?.unwrap_or_default(); let endpoint = if trimmed.is_empty() { format!("repos/{github_repo}/contents") } else { @@ -2690,10 +2809,21 @@ pub fn list_repo_directories(github_repo: &str, path: &str) -> Result { let msg = e.to_string(); - if msg.contains("Not Found") || msg.contains("HTTP 404") { - Ok(vec![]) - } else { - Err(e) + if is_github_not_found_error(&msg) { + return Ok(vec![]); + } + + match list_local_repo_directories(github_repo, &trimmed)? { + Some(dirs) => { + log::warn!( + "listed directories for '{}' in '{}' from local clone after GitHub listing failed: {}", + trimmed, + github_repo, + msg + ); + Ok(dirs) + } + None => Err(e), } } } @@ -2802,6 +2932,46 @@ mod tests { } } + #[test] + fn test_local_subpath_is_dir_accepts_relative_directory() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join("packages/app")).expect("create dirs"); + + assert!(local_subpath_is_dir(temp.path(), "packages/app").unwrap()); + assert!(local_subpath_is_dir(temp.path(), "packages/app/").unwrap()); + assert!(!local_subpath_is_dir(temp.path(), "packages/missing").unwrap()); + } + + #[test] + fn test_local_subpath_is_dir_rejects_unsafe_paths() { + let temp = tempfile::tempdir().expect("tempdir"); + for path in [ + "/tmp", + ".", + "packages/.", + "..", + "../repo", + "packages/../app", + "packages//app", + ] { + let err = local_subpath_is_dir(temp.path(), path).unwrap_err(); + assert_eq!(err.to_string(), "git command failed: Invalid path in repo"); + } + } + + #[test] + fn test_list_local_directories_at_clone_returns_sorted_child_dirs() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join("packages/b")).expect("create b"); + std::fs::create_dir_all(temp.path().join("packages/a")).expect("create a"); + std::fs::write(temp.path().join("packages/file.txt"), "not a dir").expect("write file"); + + assert_eq!( + list_local_directories_at_clone(temp.path(), "packages").unwrap(), + vec!["a".to_string(), "b".to_string()] + ); + } + #[test] fn test_check_github_auth_returns_status() { // This test just verifies the function runs without panicking diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 7b6343498..f51a495b4 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1542,13 +1542,12 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result(null); let repoConfigApi = $state< | { - waitForSubpathValidation: () => Promise; + waitForSubpathValidation: () => Promise<{ valid: boolean; error?: string }>; selectRepo: (selection: RepoSelection) => void; reset: () => void; } @@ -74,9 +74,9 @@ try { // Validate subpath if non-empty if (subpath.trim() && repoConfigApi) { - const isValid = await repoConfigApi.waitForSubpathValidation(); - if (!isValid) { - error = 'Invalid path in repo'; + const validation = await repoConfigApi.waitForSubpathValidation(); + if (!validation.valid) { + error = validation.error ?? 'Invalid path in repo'; saving = false; return; } diff --git a/apps/staged/src/lib/features/projects/NewProjectForm.svelte b/apps/staged/src/lib/features/projects/NewProjectForm.svelte index cbdea4cb5..3a25c450a 100644 --- a/apps/staged/src/lib/features/projects/NewProjectForm.svelte +++ b/apps/staged/src/lib/features/projects/NewProjectForm.svelte @@ -50,7 +50,7 @@ let error = $state(null); let repoConfigApi = $state< | { - waitForSubpathValidation: () => Promise; + waitForSubpathValidation: () => Promise<{ valid: boolean; error?: string }>; selectRepo: (selection: RepoSelection) => void; reset: () => void; } @@ -84,9 +84,9 @@ try { // If there's a subpath and a repo, validate before creating if (selectedRepo && subpath.trim() && repoConfigApi) { - const isValid = await repoConfigApi.waitForSubpathValidation(); - if (!isValid) { - error = 'Invalid path in repo'; + const validation = await repoConfigApi.waitForSubpathValidation(); + if (!validation.valid) { + error = validation.error ?? 'Invalid path in repo'; saving = false; return; } diff --git a/apps/staged/src/lib/features/projects/RepoConfigForm.svelte b/apps/staged/src/lib/features/projects/RepoConfigForm.svelte index daa39a408..5a49cf57e 100644 --- a/apps/staged/src/lib/features/projects/RepoConfigForm.svelte +++ b/apps/staged/src/lib/features/projects/RepoConfigForm.svelte @@ -25,7 +25,7 @@ import Spinner from '../../shared/Spinner.svelte'; import RepoSearchInput from './RepoSearchInput.svelte'; import SubpathInput from './SubpathInput.svelte'; - import type { SubpathInputApi } from './SubpathInput.svelte'; + import type { SubpathInputApi, SubpathValidationResult } from './SubpathInput.svelte'; import BranchPicker, { type BranchSelection } from './BranchPicker.svelte'; import type { RepoSelection } from '../../shared/githubUrl'; import { viewport } from '../../shared/viewport.svelte'; @@ -58,7 +58,7 @@ // Exposed API for parent validation and programmatic repo selection api?: { - waitForSubpathValidation: () => Promise; + waitForSubpathValidation: () => Promise; selectRepo: (selection: RepoSelection) => void; reset: () => void; }; @@ -79,7 +79,7 @@ onBranchSelected, api = $bindable< | { - waitForSubpathValidation: () => Promise; + waitForSubpathValidation: () => Promise; selectRepo: (selection: RepoSelection) => void; reset: () => void; } @@ -197,7 +197,7 @@ api = { waitForSubpathValidation: subpathApi ? () => subpathApi!.waitForValidation() - : () => Promise.resolve(true), + : () => Promise.resolve({ valid: true }), selectRepo: handleRepoSelected, reset, }; diff --git a/apps/staged/src/lib/features/projects/SubpathInput.svelte b/apps/staged/src/lib/features/projects/SubpathInput.svelte index 5e8117476..e37c3b756 100644 --- a/apps/staged/src/lib/features/projects/SubpathInput.svelte +++ b/apps/staged/src/lib/features/projects/SubpathInput.svelte @@ -6,8 +6,13 @@ - Exposes waitForValidation() so parent can validate on submit --> @@ -46,6 +51,12 @@ return val.trim().replace(/^\/+|\/+$/g, ''); } + function validationErrorMessage(error: unknown): string { + const message = + typeof error === 'string' ? error : error instanceof Error ? error.message : String(error); + return message.replace(/^git command failed:\s*/i, '') || 'Invalid path in repo'; + } + // Split the value into parent path and current segment for suggestions. // For "apps/on" → parent="apps", segment="on" // For "apps" → parent="", segment="apps" @@ -117,20 +128,20 @@ } /** - * Returns a promise that resolves to true if the current subpath is valid, - * or false if validation fails. Called by the parent on submit. + * Returns a promise that resolves to a validation result for the current + * subpath. Called by the parent on submit. */ - async function waitForValidation(): Promise { + async function waitForValidation(): Promise { const trimmed = normalize(value); if (!trimmed) { - return true; + return { valid: true }; } try { await commands.validateSubpath(repo, trimmed); - return true; - } catch { - return false; + return { valid: true }; + } catch (error) { + return { valid: false, error: validationErrorMessage(error) }; } } From f42929c0619da6ab27d40536e210aeb5c6f62037 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 25 Jun 2026 15:39:12 +1000 Subject: [PATCH 2/4] fix: preserve offline repo fallbacks Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/branches.rs | 42 +++++++++++++++++++++++++ apps/staged/src-tauri/src/git/github.rs | 37 +++++++++++++++------- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 7a7330d8a..6c0b857df 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -2606,4 +2606,46 @@ mod tests { None ); } + + #[test] + fn fetch_offline_fallback_allows_worktree_creation_from_existing_origin_ref() { + let repo = TempGitRepo::new(); + repo.write_file("README.md", "hello"); + repo.commit("initial"); + repo.run_git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); + + let github_repo = "owner/repo"; + let https_url = format!("https://github.com/{github_repo}.git"); + let missing_origin = repo.path().join("missing-origin"); + let missing_url = format!("file://{}", missing_origin.display()); + repo.run_git(&["remote", "add", "origin", &https_url]); + repo.run_git(&[ + "config", + "--add", + &format!("url.{missing_url}.insteadOf"), + &https_url, + ]); + + fetch_for_worktree_with_offline_fallback( + repo.path(), + github_repo, + "feature/offline", + "origin/main", + ) + .expect("existing origin/main should allow offline fallback"); + + let worktree_parent = tempfile::tempdir().expect("worktree tempdir"); + let worktree_path = worktree_parent.path().join("feature-offline"); + let created = create_worktree_with_fallback( + repo.path(), + "feature/offline", + "origin/main", + &worktree_path, + None, + ) + .expect("worktree should be created from stale origin/main"); + + assert_eq!(created, worktree_path); + assert!(created.join("README.md").is_file()); + } } diff --git a/apps/staged/src-tauri/src/git/github.rs b/apps/staged/src-tauri/src/git/github.rs index 041a5adad..9040b5b4f 100644 --- a/apps/staged/src-tauri/src/git/github.rs +++ b/apps/staged/src-tauri/src/git/github.rs @@ -2680,6 +2680,18 @@ fn local_repo_subpath_is_dir(github_repo: &str, subpath: &str) -> Result, + github_error_msg: &str, +) -> Option> { + match local_subpath_is_dir { + Some(true) => Some(Ok(())), + Some(false) => Some(Err(invalid_repo_path_error())), + None if is_github_not_found_error(github_error_msg) => Some(Err(invalid_repo_path_error())), + None => None, + } +} + fn list_local_directories_at_clone(clone_path: &Path, path: &str) -> Result, GitError> { let target = match normalize_repo_subpath(path)? { Some(relative_path) => clone_path.join(relative_path), @@ -2747,12 +2759,9 @@ pub fn validate_subpath_in_repo(github_repo: &str, subpath: &str) -> Result<(), } Err(e) => { let msg = e.to_string(); - if is_github_not_found_error(&msg) { - return Err(invalid_repo_path_error()); - } - - match local_repo_subpath_is_dir(github_repo, &trimmed)? { - Some(true) => { + let local_result = local_repo_subpath_is_dir(github_repo, &trimmed)?; + match validation_result_after_github_error(local_result, &msg) { + Some(Ok(())) => { log::warn!( "validated '{}' in '{}' from local clone after GitHub validation failed: {}", trimmed, @@ -2761,7 +2770,7 @@ pub fn validate_subpath_in_repo(github_repo: &str, subpath: &str) -> Result<(), ); Ok(()) } - Some(false) => Err(invalid_repo_path_error()), + Some(Err(err)) => Err(err), None => Err(e), } } @@ -2809,10 +2818,6 @@ pub fn list_repo_directories(github_repo: &str, path: &str) -> Result { let msg = e.to_string(); - if is_github_not_found_error(&msg) { - return Ok(vec![]); - } - match list_local_repo_directories(github_repo, &trimmed)? { Some(dirs) => { log::warn!( @@ -2823,6 +2828,7 @@ pub fn list_repo_directories(github_repo: &str, path: &str) -> Result Ok(vec![]), None => Err(e), } } @@ -2972,6 +2978,15 @@ mod tests { ); } + #[test] + fn test_validation_result_after_github_404_prefers_existing_local_dir() { + assert!( + validation_result_after_github_error(Some(true), "HTTP 404 Not Found") + .expect("local fallback should decide validation") + .is_ok() + ); + } + #[test] fn test_check_github_auth_returns_status() { // This test just verifies the function runs without panicking From 1ea338a1edd60c8e0793b9dadcdb104ab24b3419 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 29 Jun 2026 10:48:47 +1000 Subject: [PATCH 3/4] fix: validate fallback subpaths via git tree Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/git/github.rs | 73 +++++++++++++++++++------ 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/apps/staged/src-tauri/src/git/github.rs b/apps/staged/src-tauri/src/git/github.rs index 9040b5b4f..13f7a75d9 100644 --- a/apps/staged/src-tauri/src/git/github.rs +++ b/apps/staged/src-tauri/src/git/github.rs @@ -2663,28 +2663,37 @@ fn normalized_repo_subpath_string(subpath: &str) -> Result, GitEr )) } -fn local_subpath_is_dir(clone_path: &Path, subpath: &str) -> Result { - let Some(relative_path) = normalize_repo_subpath(subpath)? else { +fn local_subpath_is_tracked_dir(clone_path: &Path, subpath: &str) -> Result { + let Some(git_path) = normalized_repo_subpath_string(subpath)? else { return Ok(true); }; - Ok(clone_path.join(relative_path).is_dir()) + + let object = format!("HEAD:{git_path}"); + match super::cli::run_lite(clone_path, &["cat-file", "-t", &object]) { + Ok(kind) => Ok(kind.trim() == "tree"), + Err(GitError::CommandFailed(_)) => Ok(false), + Err(err) => Err(err), + } } -fn local_repo_subpath_is_dir(github_repo: &str, subpath: &str) -> Result, GitError> { +fn local_repo_subpath_is_tracked_dir( + github_repo: &str, + subpath: &str, +) -> Result, GitError> { let Some(clone_path) = crate::paths::clone_path_for(github_repo) else { return Ok(None); }; if !clone_path.join(".git").exists() { return Ok(None); } - local_subpath_is_dir(&clone_path, subpath).map(Some) + local_subpath_is_tracked_dir(&clone_path, subpath).map(Some) } fn validation_result_after_github_error( - local_subpath_is_dir: Option, + local_subpath_is_tracked_dir: Option, github_error_msg: &str, ) -> Option> { - match local_subpath_is_dir { + match local_subpath_is_tracked_dir { Some(true) => Some(Ok(())), Some(false) => Some(Err(invalid_repo_path_error())), None if is_github_not_found_error(github_error_msg) => Some(Err(invalid_repo_path_error())), @@ -2737,7 +2746,8 @@ fn list_local_repo_directories( /// /// Uses the GitHub contents API to check that the path exists and is a /// directory (the API returns an array for directories). If GitHub cannot be -/// reached, falls back to the existing local clone when one is available. +/// reached, falls back to the existing local clone's HEAD tree when one is +/// available. /// Returns an error if the path does not exist or points to a file rather than /// a directory. pub fn validate_subpath_in_repo(github_repo: &str, subpath: &str) -> Result<(), GitError> { @@ -2759,7 +2769,7 @@ pub fn validate_subpath_in_repo(github_repo: &str, subpath: &str) -> Result<(), } Err(e) => { let msg = e.to_string(); - let local_result = local_repo_subpath_is_dir(github_repo, &trimmed)?; + let local_result = local_repo_subpath_is_tracked_dir(github_repo, &trimmed)?; match validation_result_after_github_error(local_result, &msg) { Some(Ok(())) => { log::warn!( @@ -2939,17 +2949,19 @@ mod tests { } #[test] - fn test_local_subpath_is_dir_accepts_relative_directory() { - let temp = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(temp.path().join("packages/app")).expect("create dirs"); + fn test_local_subpath_is_tracked_dir_accepts_tracked_relative_directory() { + let repo = crate::test_utils::TempGitRepo::new(); + std::fs::create_dir_all(repo.path().join("packages/app")).expect("create dirs"); + std::fs::write(repo.path().join("packages/app/file.txt"), "tracked").expect("write file"); + repo.commit("init"); - assert!(local_subpath_is_dir(temp.path(), "packages/app").unwrap()); - assert!(local_subpath_is_dir(temp.path(), "packages/app/").unwrap()); - assert!(!local_subpath_is_dir(temp.path(), "packages/missing").unwrap()); + assert!(local_subpath_is_tracked_dir(repo.path(), "packages/app").unwrap()); + assert!(local_subpath_is_tracked_dir(repo.path(), "packages/app/").unwrap()); + assert!(!local_subpath_is_tracked_dir(repo.path(), "packages/missing").unwrap()); } #[test] - fn test_local_subpath_is_dir_rejects_unsafe_paths() { + fn test_local_subpath_is_tracked_dir_rejects_unsafe_paths() { let temp = tempfile::tempdir().expect("tempdir"); for path in [ "/tmp", @@ -2960,11 +2972,38 @@ mod tests { "packages/../app", "packages//app", ] { - let err = local_subpath_is_dir(temp.path(), path).unwrap_err(); + let err = local_subpath_is_tracked_dir(temp.path(), path).unwrap_err(); assert_eq!(err.to_string(), "git command failed: Invalid path in repo"); } } + #[test] + fn test_local_subpath_is_tracked_dir_rejects_untracked_git_and_files() { + let repo = crate::test_utils::TempGitRepo::new(); + std::fs::create_dir_all(repo.path().join("packages/app")).expect("create app dir"); + std::fs::write(repo.path().join("packages/app/file.txt"), "tracked").expect("write file"); + repo.commit("init"); + + std::fs::create_dir_all(repo.path().join("node_modules/pkg")) + .expect("create untracked dir"); + + assert!(!local_subpath_is_tracked_dir(repo.path(), "node_modules").unwrap()); + assert!(!local_subpath_is_tracked_dir(repo.path(), ".git").unwrap()); + assert!(!local_subpath_is_tracked_dir(repo.path(), "packages/app/file.txt").unwrap()); + } + + #[cfg(unix)] + #[test] + fn test_local_subpath_is_tracked_dir_rejects_symlinked_directory() { + let repo = crate::test_utils::TempGitRepo::new(); + let external = tempfile::tempdir().expect("external tempdir"); + std::os::unix::fs::symlink(external.path(), repo.path().join("external-link")) + .expect("create symlink"); + repo.commit("init"); + + assert!(!local_subpath_is_tracked_dir(repo.path(), "external-link").unwrap()); + } + #[test] fn test_list_local_directories_at_clone_returns_sorted_child_dirs() { let temp = tempfile::tempdir().expect("tempdir"); From 6c02c40df926abb71ad297485a14f39f2e6e4dd4 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 29 Jun 2026 10:57:37 +1000 Subject: [PATCH 4/4] fix: reject stale fallback for missing base refs Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/branches.rs | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 6c0b857df..65c057318 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -684,6 +684,11 @@ fn local_base_ref_for_worktree(repo_path: &Path, base_branch: &str) -> Option bool { + let lower = fetch_err.to_ascii_lowercase(); + lower.contains("couldn't find remote ref") || lower.contains("could not find remote ref") +} + pub(crate) fn fetch_for_worktree_with_offline_fallback( repo_path: &Path, repo_slug: &str, @@ -693,6 +698,11 @@ pub(crate) fn fetch_for_worktree_with_offline_fallback( match git::fetch_for_worktree(repo_path, repo_slug, branch_name, base_branch) { Ok(()) => Ok(()), Err(fetch_err) => { + let fetch_err = fetch_err.to_string(); + if is_missing_remote_ref_fetch_error(&fetch_err) { + return Err(fetch_err); + } + if let Some(base_ref) = local_base_ref_for_worktree(repo_path, base_branch) { log::warn!( "fetch for worktree branch '{}' in '{}' failed; using stale local ref '{}': {}", @@ -2648,4 +2658,40 @@ mod tests { assert_eq!(created, worktree_path); assert!(created.join("README.md").is_file()); } + + #[test] + fn fetch_offline_fallback_rejects_deleted_remote_base_ref() { + let remote = TempGitRepo::new(); + remote.write_file("README.md", "hello from remote"); + remote.commit("initial"); + + let repo = TempGitRepo::new(); + repo.write_file("README.md", "hello"); + repo.commit("initial"); + repo.run_git(&["update-ref", "refs/remotes/origin/deleted-base", "HEAD"]); + + let github_repo = "owner/repo"; + let https_url = format!("https://github.com/{github_repo}.git"); + let remote_url = format!("file://{}", remote.path().display()); + repo.run_git(&["remote", "add", "origin", &remote_url]); + repo.run_git(&[ + "config", + "--add", + &format!("url.{remote_url}.insteadOf"), + &https_url, + ]); + + let err = fetch_for_worktree_with_offline_fallback( + repo.path(), + github_repo, + "feature/offline", + "origin/deleted-base", + ) + .expect_err("missing remote base ref should not use stale origin/deleted-base"); + + assert!( + is_missing_remote_ref_fetch_error(&err), + "expected missing remote ref error, got: {err}" + ); + } }