From 46a8f312e1d9bb8d0e543e4facc5e1856e770b77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 05:33:01 +0200 Subject: [PATCH 1/6] Offer to initialise a repository when adding a simple directory as project Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- apps/desktop/src/lib/error/knownErrors.ts | 6 ++- .../src/lib/project/projectsService.ts | 4 ++ .../src/routes/[projectId]/+layout.svelte | 42 +++++++++++++++++- crates/gitbutler-error/src/error.rs | 5 +++ crates/gitbutler-tauri/src/main.rs | 1 + crates/gitbutler-tauri/src/projects.rs | 43 +++++++++++++++++++ 6 files changed, 98 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/error/knownErrors.ts b/apps/desktop/src/lib/error/knownErrors.ts index 68dbf55576..b54b01ffc5 100644 --- a/apps/desktop/src/lib/error/knownErrors.ts +++ b/apps/desktop/src/lib/error/knownErrors.ts @@ -8,7 +8,8 @@ export enum Code { ProjectMissing = 'errors.projects.missing', SecretKeychainNotFound = 'errors.secret.keychain_notfound', MissingLoginKeychain = 'errors.secret.missing_login_keychain', - GitHubTokenExpired = 'errors.github.expired_token' + GitHubTokenExpired = 'errors.github.expired_token', + NonGitRepository = 'errors.projects.not_git_repository' } export const KNOWN_ERRORS: Record = { @@ -34,5 +35,8 @@ With \`seahorse\` or equivalent, create a \`Login\` password store, right click `, [Code.GitHubTokenExpired]: ` Your GitHub token appears expired, please check your settings! + `, + [Code.NonGitRepository]: ` +The selected directory is not a Git repository. Would you like to initialize a Git repository in this directory? ` }; diff --git a/apps/desktop/src/lib/project/projectsService.ts b/apps/desktop/src/lib/project/projectsService.ts index ab11e514aa..a598c2c0da 100644 --- a/apps/desktop/src/lib/project/projectsService.ts +++ b/apps/desktop/src/lib/project/projectsService.ts @@ -137,6 +137,10 @@ export class ProjectsService { unsetLastOpenedProject() { this.persistedId.set(undefined); } + + async initGitRepository(path: string): Promise { + return await this.backend.invoke('init_git_repository', { path }); + } } function injectEndpoints(api: ClientState['backendApi']) { diff --git a/apps/desktop/src/routes/[projectId]/+layout.svelte b/apps/desktop/src/routes/[projectId]/+layout.svelte index 33f56a25e2..e9a822622a 100644 --- a/apps/desktop/src/routes/[projectId]/+layout.svelte +++ b/apps/desktop/src/routes/[projectId]/+layout.svelte @@ -12,6 +12,7 @@ import ReduxResult from '$components/ReduxResult.svelte'; import { OnboardingEvent, POSTHOG_WRAPPER } from '$lib/analytics/posthog'; import { BACKEND } from '$lib/backend'; +import { getUserErrorCode, Code } from '$lib/backend/ipc'; import { BASE_BRANCH_SERVICE } from '$lib/baseBranch/baseBranchService.svelte'; import { BRANCH_SERVICE } from '$lib/branches/branchService.svelte'; import { SETTINGS_SERVICE } from '$lib/config/appSettingsV2'; @@ -22,7 +23,7 @@ import { GITLAB_STATE } from '$lib/forge/gitlab/gitlabState.svelte'; import { GIT_SERVICE } from '$lib/git/gitService'; import { MODE_SERVICE } from '$lib/mode/modeService'; - import { showError, showInfo, showWarning } from '$lib/notifications/toasts'; + import { showError, showInfo, showWarning, showToast } from '$lib/notifications/toasts'; import { PROJECTS_SERVICE } from '$lib/project/projectsService'; import { FILE_SELECTION_MANAGER } from '$lib/selection/fileSelectionManager.svelte'; import { UNCOMMITTED_SERVICE } from '$lib/selection/uncommittedService.svelte'; @@ -320,7 +321,44 @@ } } catch (error: unknown) { posthog.captureOnboarding(OnboardingEvent.SetProjectActiveFailed); - showError('Failed to set the project active', error); + + // Check if this is a non-git repository error + const errorCode = getUserErrorCode(error); + if (errorCode === Code.NonGitRepository) { + // Show special toast with git init option + showToast({ + title: 'Not a Git repository', + message: 'The selected directory is not a Git repository. Would you like to initialize one?', + style: 'warning', + extraAction: { + label: 'Initialize Repository', + onClick: async (dismiss) => { + try { + const currentProject = projects?.find((p) => p.id === projectId); + if (currentProject?.path) { + await projectsService.initGitRepository(currentProject.path); + dismiss(); + showToast({ + title: 'Repository Initialized', + message: `Git repository has been successfully initialized at ${currentProject.path}. Loading project...`, + style: 'info' + }); + // Retry setting the project active + await setActiveProjectOrRedirect(projectId); + } else { + throw new Error('Could not find project path'); + } + } catch (initError) { + console.error('Failed to initialize repository:', initError); + dismiss(); + showError('Failed to initialize repository', initError); + } + } + } + }); + } else { + showError('Failed to set the project active', error); + } } } diff --git a/crates/gitbutler-error/src/error.rs b/crates/gitbutler-error/src/error.rs index dd67eee316..f5b710dba1 100644 --- a/crates/gitbutler-error/src/error.rs +++ b/crates/gitbutler-error/src/error.rs @@ -139,6 +139,10 @@ pub enum Code { SecretKeychainNotFound, MissingLoginKeychain, GitForcePushProtection, + /// When trying to open a project that is not in a git repository, + /// this error code is used to trigger special handling in the frontend. + /// The frontend will show a modal offering to initialize a git repository. + NonGitRepository, } impl std::fmt::Display for Code { @@ -157,6 +161,7 @@ impl std::fmt::Display for Code { Code::SecretKeychainNotFound => "errors.secret.keychain_notfound", Code::MissingLoginKeychain => "errors.secret.missing_login_keychain", Code::GitForcePushProtection => "errors.git.force_push_protection", + Code::NonGitRepository => "errors.projects.not_git_repository", }; f.write_str(code) } diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 6ed36524e6..aee79e9640 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -227,6 +227,7 @@ fn main() { projects::list_projects, projects::set_project_active, projects::open_project_in_window, + projects::init_git_repository, repo::git_get_local_config, repo::git_set_local_config, repo::check_signing_settings, diff --git a/crates/gitbutler-tauri/src/projects.rs b/crates/gitbutler-tauri/src/projects.rs index 30ca6dfe41..8002b255d6 100644 --- a/crates/gitbutler-tauri/src/projects.rs +++ b/crates/gitbutler-tauri/src/projects.rs @@ -69,6 +69,14 @@ pub fn set_project_active( let err = anyhow::Error::from(err); if code == git2::ErrorCode::Owner { err.context(gitbutler_error::error::Code::RepoOwnership) + } else if code == git2::ErrorCode::NotFound || + code == git2::ErrorCode::Invalid || + code == git2::ErrorCode::Config { + // Common error codes when a directory is not a git repository: + // - NotFound: .git directory or repository not found + // - Invalid: Invalid repository structure + // - Config: Repository configuration issues + err.context(gitbutler_error::error::Code::NonGitRepository) } else { err } @@ -212,3 +220,38 @@ Ensure these aren't touched by GitButler or avoid using it in this repository.", } Ok(Some(msg)) } + +/// Initialize a Git repository at the given path +#[tauri::command] +#[instrument(err(Debug))] +pub fn init_git_repository(path: String) -> Result<(), Error> { + let repo_path = std::path::Path::new(&path); + + // Validate path + if !repo_path.exists() { + return Err(anyhow::anyhow!("Path does not exist: {}", path).into()); + } + if !repo_path.is_dir() { + return Err(anyhow::anyhow!("Path is not a directory: {}", path).into()); + } + + // Check if it's already a git repository + if git2::Repository::open(repo_path).is_ok() { + return Err(anyhow::anyhow!("Directory is already a Git repository").into()); + } + + // Check if directory is writable + let temp_file = repo_path.join(".gitbutler_write_test"); + if let Err(_) = std::fs::write(&temp_file, "test") { + return Err(anyhow::anyhow!("Directory is not writable: {}", path).into()); + } + // Clean up test file + let _ = std::fs::remove_file(&temp_file); + + // Initialize the repository + git2::Repository::init(repo_path) + .with_context(|| format!("Failed to initialize Git repository at {}", path))?; + + tracing::info!("Successfully initialized Git repository at {}", path); + Ok(()) +} From 256b2c84591d4300cb04b3b1cb7cdee1e28ca31d Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 29 Sep 2025 05:34:14 +0200 Subject: [PATCH 2/6] refactor --- apps/desktop/src/lib/error/knownErrors.ts | 6 +-- apps/desktop/src/lib/project/project.ts | 30 +++++++++++-- .../src/routes/[projectId]/+layout.svelte | 42 +------------------ crates/gitbutler-error/src/error.rs | 5 --- crates/gitbutler-tauri/src/projects.rs | 39 ++--------------- 5 files changed, 32 insertions(+), 90 deletions(-) diff --git a/apps/desktop/src/lib/error/knownErrors.ts b/apps/desktop/src/lib/error/knownErrors.ts index b54b01ffc5..68dbf55576 100644 --- a/apps/desktop/src/lib/error/knownErrors.ts +++ b/apps/desktop/src/lib/error/knownErrors.ts @@ -8,8 +8,7 @@ export enum Code { ProjectMissing = 'errors.projects.missing', SecretKeychainNotFound = 'errors.secret.keychain_notfound', MissingLoginKeychain = 'errors.secret.missing_login_keychain', - GitHubTokenExpired = 'errors.github.expired_token', - NonGitRepository = 'errors.projects.not_git_repository' + GitHubTokenExpired = 'errors.github.expired_token' } export const KNOWN_ERRORS: Record = { @@ -35,8 +34,5 @@ With \`seahorse\` or equivalent, create a \`Login\` password store, right click `, [Code.GitHubTokenExpired]: ` Your GitHub token appears expired, please check your settings! - `, - [Code.NonGitRepository]: ` -The selected directory is not a Git repository. Would you like to initialize a Git repository in this directory? ` }; diff --git a/apps/desktop/src/lib/project/project.ts b/apps/desktop/src/lib/project/project.ts index 33a1c12871..ba0e9c65fd 100644 --- a/apps/desktop/src/lib/project/project.ts +++ b/apps/desktop/src/lib/project/project.ts @@ -1,5 +1,5 @@ import { goto } from '$app/navigation'; -import { showToast } from '$lib/notifications/toasts'; +import { showError, showToast } from '$lib/notifications/toasts'; import { projectPath } from '$lib/routes/routes.svelte'; import { TestId } from '@gitbutler/ui'; import type { ForgeName } from '$lib/forge/interface/forge'; @@ -162,10 +162,32 @@ export function handleAddProjectOutcome( return true; case 'notAGitRepository': showToast({ - testId: TestId.AddProjectNotAGitRepoModal, - style: 'warning', title: 'Not a Git repository', - message: `Unable to add project: ${outcome.subject}` + message: + 'The selected directory is not a Git repository. Would you like to initialize one?', + style: 'warning', + extraAction: { + label: 'Initialize Repository', + onClick: async (dismiss) => { + try { + const projectPath = 'get this - needs a refactor probably'; + await projectsService.initGitRepository(projectPath); + dismiss(); + showToast({ + title: 'Repository Initialized', + message: `Git repository has been successfully initialized at ${projectPath}. Loading project...`, + style: 'info' + }); + const projectId = 'TODO: get this from somewhere'; + // Retry setting the project active + await setActiveProjectOrRedirect(projectId); + } catch (initError) { + console.error('Failed to initialize repository:', initError); + dismiss(); + showError('Failed to initialize repository', initError); + } + } + } }); return true; } diff --git a/apps/desktop/src/routes/[projectId]/+layout.svelte b/apps/desktop/src/routes/[projectId]/+layout.svelte index e9a822622a..33f56a25e2 100644 --- a/apps/desktop/src/routes/[projectId]/+layout.svelte +++ b/apps/desktop/src/routes/[projectId]/+layout.svelte @@ -12,7 +12,6 @@ import ReduxResult from '$components/ReduxResult.svelte'; import { OnboardingEvent, POSTHOG_WRAPPER } from '$lib/analytics/posthog'; import { BACKEND } from '$lib/backend'; -import { getUserErrorCode, Code } from '$lib/backend/ipc'; import { BASE_BRANCH_SERVICE } from '$lib/baseBranch/baseBranchService.svelte'; import { BRANCH_SERVICE } from '$lib/branches/branchService.svelte'; import { SETTINGS_SERVICE } from '$lib/config/appSettingsV2'; @@ -23,7 +22,7 @@ import { getUserErrorCode, Code } from '$lib/backend/ipc'; import { GITLAB_STATE } from '$lib/forge/gitlab/gitlabState.svelte'; import { GIT_SERVICE } from '$lib/git/gitService'; import { MODE_SERVICE } from '$lib/mode/modeService'; - import { showError, showInfo, showWarning, showToast } from '$lib/notifications/toasts'; + import { showError, showInfo, showWarning } from '$lib/notifications/toasts'; import { PROJECTS_SERVICE } from '$lib/project/projectsService'; import { FILE_SELECTION_MANAGER } from '$lib/selection/fileSelectionManager.svelte'; import { UNCOMMITTED_SERVICE } from '$lib/selection/uncommittedService.svelte'; @@ -321,44 +320,7 @@ import { getUserErrorCode, Code } from '$lib/backend/ipc'; } } catch (error: unknown) { posthog.captureOnboarding(OnboardingEvent.SetProjectActiveFailed); - - // Check if this is a non-git repository error - const errorCode = getUserErrorCode(error); - if (errorCode === Code.NonGitRepository) { - // Show special toast with git init option - showToast({ - title: 'Not a Git repository', - message: 'The selected directory is not a Git repository. Would you like to initialize one?', - style: 'warning', - extraAction: { - label: 'Initialize Repository', - onClick: async (dismiss) => { - try { - const currentProject = projects?.find((p) => p.id === projectId); - if (currentProject?.path) { - await projectsService.initGitRepository(currentProject.path); - dismiss(); - showToast({ - title: 'Repository Initialized', - message: `Git repository has been successfully initialized at ${currentProject.path}. Loading project...`, - style: 'info' - }); - // Retry setting the project active - await setActiveProjectOrRedirect(projectId); - } else { - throw new Error('Could not find project path'); - } - } catch (initError) { - console.error('Failed to initialize repository:', initError); - dismiss(); - showError('Failed to initialize repository', initError); - } - } - } - }); - } else { - showError('Failed to set the project active', error); - } + showError('Failed to set the project active', error); } } diff --git a/crates/gitbutler-error/src/error.rs b/crates/gitbutler-error/src/error.rs index f5b710dba1..dd67eee316 100644 --- a/crates/gitbutler-error/src/error.rs +++ b/crates/gitbutler-error/src/error.rs @@ -139,10 +139,6 @@ pub enum Code { SecretKeychainNotFound, MissingLoginKeychain, GitForcePushProtection, - /// When trying to open a project that is not in a git repository, - /// this error code is used to trigger special handling in the frontend. - /// The frontend will show a modal offering to initialize a git repository. - NonGitRepository, } impl std::fmt::Display for Code { @@ -161,7 +157,6 @@ impl std::fmt::Display for Code { Code::SecretKeychainNotFound => "errors.secret.keychain_notfound", Code::MissingLoginKeychain => "errors.secret.missing_login_keychain", Code::GitForcePushProtection => "errors.git.force_push_protection", - Code::NonGitRepository => "errors.projects.not_git_repository", }; f.write_str(code) } diff --git a/crates/gitbutler-tauri/src/projects.rs b/crates/gitbutler-tauri/src/projects.rs index 8002b255d6..5e600abbe1 100644 --- a/crates/gitbutler-tauri/src/projects.rs +++ b/crates/gitbutler-tauri/src/projects.rs @@ -69,14 +69,6 @@ pub fn set_project_active( let err = anyhow::Error::from(err); if code == git2::ErrorCode::Owner { err.context(gitbutler_error::error::Code::RepoOwnership) - } else if code == git2::ErrorCode::NotFound || - code == git2::ErrorCode::Invalid || - code == git2::ErrorCode::Config { - // Common error codes when a directory is not a git repository: - // - NotFound: .git directory or repository not found - // - Invalid: Invalid repository structure - // - Config: Repository configuration issues - err.context(gitbutler_error::error::Code::NonGitRepository) } else { err } @@ -225,33 +217,8 @@ Ensure these aren't touched by GitButler or avoid using it in this repository.", #[tauri::command] #[instrument(err(Debug))] pub fn init_git_repository(path: String) -> Result<(), Error> { - let repo_path = std::path::Path::new(&path); - - // Validate path - if !repo_path.exists() { - return Err(anyhow::anyhow!("Path does not exist: {}", path).into()); - } - if !repo_path.is_dir() { - return Err(anyhow::anyhow!("Path is not a directory: {}", path).into()); - } - - // Check if it's already a git repository - if git2::Repository::open(repo_path).is_ok() { - return Err(anyhow::anyhow!("Directory is already a Git repository").into()); - } - - // Check if directory is writable - let temp_file = repo_path.join(".gitbutler_write_test"); - if let Err(_) = std::fs::write(&temp_file, "test") { - return Err(anyhow::anyhow!("Directory is not writable: {}", path).into()); - } - // Clean up test file - let _ = std::fs::remove_file(&temp_file); - - // Initialize the repository - git2::Repository::init(repo_path) - .with_context(|| format!("Failed to initialize Git repository at {}", path))?; - - tracing::info!("Successfully initialized Git repository at {}", path); + let path: PathBuf = path.into(); + git2::Repository::init(&path) + .with_context(|| format!("Failed to initialize Git repository at {}", path.display()))?; Ok(()) } From 92478b7dafbb157969c6d435192b861669bfa80d Mon Sep 17 00:00:00 2001 From: estib Date: Mon, 29 Sep 2025 12:34:46 +0200 Subject: [PATCH 3/6] Handle correctly the initialization of a repository on adding a non-git directory --- .../src/components/ChromeHeader.svelte | 13 +++---- apps/desktop/src/components/CloneForm.svelte | 24 ++++++------- .../src/components/FileMenuAction.svelte | 15 +++----- .../src/components/ProjectSwitcher.svelte | 12 +++---- apps/desktop/src/components/Welcome.svelte | 27 ++++++++++----- apps/desktop/src/lib/project/project.ts | 30 ++++++---------- .../src/lib/project/useProjects.svelte.ts | 34 +++++++++++++++++++ crates/gitbutler-project/src/controller.rs | 12 +++++-- crates/gitbutler-project/src/project.rs | 10 ++++-- 9 files changed, 104 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/lib/project/useProjects.svelte.ts diff --git a/apps/desktop/src/components/ChromeHeader.svelte b/apps/desktop/src/components/ChromeHeader.svelte index 75fce8da16..090ae0ccff 100644 --- a/apps/desktop/src/components/ChromeHeader.svelte +++ b/apps/desktop/src/components/ChromeHeader.svelte @@ -8,8 +8,8 @@ import { ircEnabled } from '$lib/config/uiFeatureFlags'; import { IRC_SERVICE } from '$lib/irc/ircService.svelte'; import { MODE_SERVICE } from '$lib/mode/modeService'; - import { handleAddProjectOutcome } from '$lib/project/project'; import { PROJECTS_SERVICE } from '$lib/project/projectsService'; + import { useAddProject } from '$lib/project/useProjects.svelte'; import { ircPath, projectPath, isWorkspacePath } from '$lib/routes/routes.svelte'; import { UI_STATE } from '$lib/state/uiState.svelte'; import { inject } from '@gitbutler/core/context'; @@ -47,6 +47,8 @@ const singleBranchMode = $derived($settingsStore?.featureFlags.singleBranch ?? false); const backend = inject(BACKEND); + const { addProject } = useAddProject(); + const mode = $derived(modeService.mode({ projectId })); const currentMode = $derived(mode.response); const currentBranchName = $derived.by(() => { @@ -188,14 +190,7 @@ onClick={async () => { newProjectLoading = true; try { - const outcome = await projectsService.addProject(); - if (!outcome) { - // User cancelled the project creation - newProjectLoading = false; - return; - } - - handleAddProjectOutcome(outcome, (project) => goto(projectPath(project.id))); + await addProject(); } finally { newProjectLoading = false; } diff --git a/apps/desktop/src/components/CloneForm.svelte b/apps/desktop/src/components/CloneForm.svelte index 71e8b12cb4..15c016f7d9 100644 --- a/apps/desktop/src/components/CloneForm.svelte +++ b/apps/desktop/src/components/CloneForm.svelte @@ -5,9 +5,7 @@ import { OnboardingEvent, POSTHOG_WRAPPER } from '$lib/analytics/posthog'; import { BACKEND } from '$lib/backend'; import { GIT_SERVICE } from '$lib/git/gitService'; - import { handleAddProjectOutcome } from '$lib/project/project'; - import { PROJECTS_SERVICE } from '$lib/project/projectsService'; - import { projectPath } from '$lib/routes/routes.svelte'; + import { useAddProject } from '$lib/project/useProjects.svelte'; import { parseRemoteUrl } from '$lib/url/gitUrl'; import { inject } from '@gitbutler/core/context'; import { persisted } from '@gitbutler/shared/persisted'; @@ -16,7 +14,6 @@ import * as Sentry from '@sentry/sveltekit'; import { onMount } from 'svelte'; - const projectsService = inject(PROJECTS_SERVICE); const gitService = inject(GIT_SERVICE); const posthog = inject(POSTHOG_WRAPPER); const backend = inject(BACKEND); @@ -62,6 +59,14 @@ return String(error); } + const { addProject } = useAddProject(() => { + posthog.captureOnboarding( + OnboardingEvent.ClonedProjectFailed, + 'Failed to add project after cloning' + ); + throw new Error('Failed to add project after cloning.'); + }); + async function cloneRepository() { loading = true; savedTargetDirPath.set(targetDirPath); @@ -88,16 +93,7 @@ await gitService.cloneRepo(repositoryUrl, targetDir); posthog.captureOnboarding(OnboardingEvent.ClonedProject); - const outcome = await projectsService.addProject(targetDir); - if (!outcome) { - posthog.captureOnboarding( - OnboardingEvent.ClonedProjectFailed, - 'Failed to add project after cloning' - ); - throw new Error('Failed to add project after cloning.'); - } - - handleAddProjectOutcome(outcome, (project) => goto(projectPath(project.id))); + await addProject(targetDir); } catch (e) { Sentry.captureException(e); const errorMessage = getErrorMessage(e); diff --git a/apps/desktop/src/components/FileMenuAction.svelte b/apps/desktop/src/components/FileMenuAction.svelte index 5d0f5fe70f..d553e31386 100644 --- a/apps/desktop/src/components/FileMenuAction.svelte +++ b/apps/desktop/src/components/FileMenuAction.svelte @@ -1,24 +1,19 @@
@@ -48,13 +50,7 @@ onClick={async () => { newProjectLoading = true; try { - const outcome = await projectsService.addProject(); - if (!outcome) { - // User cancelled the project creation - newProjectLoading = false; - return; - } - handleAddProjectOutcome(outcome, (project) => goto(projectPath(project.id))); + await addProject(); } finally { newProjectLoading = false; } diff --git a/apps/desktop/src/components/Welcome.svelte b/apps/desktop/src/components/Welcome.svelte index 00b39cf059..e084a72f01 100644 --- a/apps/desktop/src/components/Welcome.svelte +++ b/apps/desktop/src/components/Welcome.svelte @@ -6,6 +6,7 @@ import { OnboardingEvent, POSTHOG_WRAPPER } from '$lib/analytics/posthog'; import cloneRepoSvg from '$lib/assets/welcome/clone-repo.svg?raw'; import newProjectSvg from '$lib/assets/welcome/new-local-project.svg?raw'; + import { showToast } from '$lib/notifications/toasts'; import { handleAddProjectOutcome } from '$lib/project/project'; import { PROJECTS_SERVICE } from '$lib/project/projectsService'; import { inject } from '@gitbutler/core/context'; @@ -17,23 +18,33 @@ let newProjectLoading = $state(false); let directoryInputElement = $state(); - async function onNewProject() { - newProjectLoading = true; + async function addProject(path: string) { try { - const testDirectoryPath = directoryInputElement?.value; - const outcome = await projectsService.addProject(testDirectoryPath ?? ''); - + const outcome = await projectsService.addProject(path); posthog.captureOnboarding(OnboardingEvent.AddLocalProject); + if (outcome) { - handleAddProjectOutcome(outcome); + handleAddProjectOutcome(outcome, async (path: string) => { + await projectsService.initGitRepository(path); + showToast({ + title: 'Repository Initialized', + message: `Git repository has been successfully initialized at ${path}. Loading project...`, + style: 'info' + }); + await addProject(path); + }); } } catch (e: unknown) { posthog.captureOnboarding(OnboardingEvent.AddLocalProjectFailed, e); - } finally { - newProjectLoading = false; } } + async function onNewProject() { + newProjectLoading = true; + await addProject(directoryInputElement?.value ?? ''); + newProjectLoading = false; + } + async function onCloneProject() { goto('/onboarding/clone'); } diff --git a/apps/desktop/src/lib/project/project.ts b/apps/desktop/src/lib/project/project.ts index ba0e9c65fd..47ed1debb8 100644 --- a/apps/desktop/src/lib/project/project.ts +++ b/apps/desktop/src/lib/project/project.ts @@ -1,5 +1,5 @@ import { goto } from '$app/navigation'; -import { showError, showToast } from '$lib/notifications/toasts'; +import { showToast } from '$lib/notifications/toasts'; import { projectPath } from '$lib/routes/routes.svelte'; import { TestId } from '@gitbutler/ui'; import type { ForgeName } from '$lib/forge/interface/forge'; @@ -85,15 +85,19 @@ export type AddProjectOutcome = /** * The error message received */ - subject: string; + subject: { + path: string; + message: string; + }; }; /** * Correctly handle the outcome of an addProject operation by passing the project to the callback or - * showing toasts as necessary. + * showing toasts as necessary.'get this - needs a refactor probably'; */ export function handleAddProjectOutcome( outcome: AddProjectOutcome, + onInitialize: (path: string) => Promise, onAdded?: (project: Project) => void ): true { switch (outcome.type) { @@ -169,23 +173,9 @@ export function handleAddProjectOutcome( extraAction: { label: 'Initialize Repository', onClick: async (dismiss) => { - try { - const projectPath = 'get this - needs a refactor probably'; - await projectsService.initGitRepository(projectPath); - dismiss(); - showToast({ - title: 'Repository Initialized', - message: `Git repository has been successfully initialized at ${projectPath}. Loading project...`, - style: 'info' - }); - const projectId = 'TODO: get this from somewhere'; - // Retry setting the project active - await setActiveProjectOrRedirect(projectId); - } catch (initError) { - console.error('Failed to initialize repository:', initError); - dismiss(); - showError('Failed to initialize repository', initError); - } + const projectPath = outcome.subject.path; + await onInitialize(projectPath); + dismiss(); } } }); diff --git a/apps/desktop/src/lib/project/useProjects.svelte.ts b/apps/desktop/src/lib/project/useProjects.svelte.ts new file mode 100644 index 0000000000..bdb646085d --- /dev/null +++ b/apps/desktop/src/lib/project/useProjects.svelte.ts @@ -0,0 +1,34 @@ +import { goto } from '$app/navigation'; +import { showToast } from '$lib/notifications/toasts'; +import { handleAddProjectOutcome } from '$lib/project/project'; +import { PROJECTS_SERVICE } from '$lib/project/projectsService'; +import { projectPath } from '$lib/routes/routes.svelte'; +import { inject } from '@gitbutler/core/context'; + +export function useAddProject(onMissingOutcome?: () => void) { + const projectsService = inject(PROJECTS_SERVICE); + + async function addProject(path?: string) { + const outcome = await projectsService.addProject(path); + + if (outcome) { + handleAddProjectOutcome( + outcome, + async (path: string) => { + await projectsService.initGitRepository(path); + showToast({ + title: 'Repository Initialized', + message: `Git repository has been successfully initialized at ${path}. Loading project...`, + style: 'info' + }); + await addProject(path); + }, + (project) => goto(projectPath(project.id)) + ); + } else { + onMissingOutcome?.(); + } + } + + return { addProject }; +} diff --git a/crates/gitbutler-project/src/controller.rs b/crates/gitbutler-project/src/controller.rs index efaf77ecaa..f12281f6fd 100644 --- a/crates/gitbutler-project/src/controller.rs +++ b/crates/gitbutler-project/src/controller.rs @@ -4,7 +4,10 @@ use anyhow::{anyhow, bail, Context, Result}; use gitbutler_error::error; use super::{storage, storage::UpdateRequest, Project, ProjectId}; -use crate::{project::AddProjectOutcome, AuthKey}; +use crate::{ + project::{AddProjectOutcome, NotAGitRepositoryOutcome}, + AuthKey, +}; #[derive(Clone, Debug)] pub(crate) struct Controller { @@ -97,7 +100,12 @@ impl Controller { } }, Err(err) => { - return Ok(AddProjectOutcome::NotAGitRepository(err.to_string())); + return Ok(AddProjectOutcome::NotAGitRepository( + NotAGitRepositoryOutcome { + path: path.to_path_buf(), + message: err.to_string(), + }, + )); } } diff --git a/crates/gitbutler-project/src/project.rs b/crates/gitbutler-project/src/project.rs index 6a220f68a4..62b33f6775 100644 --- a/crates/gitbutler-project/src/project.rs +++ b/crates/gitbutler-project/src/project.rs @@ -183,6 +183,12 @@ impl Project { } } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NotAGitRepositoryOutcome { + pub path: PathBuf, + pub message: String, +} + #[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] #[serde(rename_all = "camelCase", tag = "type", content = "subject")] pub enum AddProjectOutcome { @@ -194,7 +200,7 @@ pub enum AddProjectOutcome { NonMainWorktree, NoWorkdir, NoDotGitDirectory, - NotAGitRepository(String), + NotAGitRepository(NotAGitRepositoryOutcome), } impl AddProjectOutcome { @@ -229,7 +235,7 @@ impl AddProjectOutcome { Err(anyhow::anyhow!("no .git directory found in repository")) } AddProjectOutcome::NotAGitRepository(msg) => { - Err(anyhow::anyhow!("not a git repository: {}", msg)) + Err(anyhow::anyhow!("not a git repository: {}", msg.message)) } } } From a0621e292d91e4073b0dc32597975236d3423bb4 Mon Sep 17 00:00:00 2001 From: estib Date: Tue, 30 Sep 2025 13:56:39 +0200 Subject: [PATCH 4/6] Move the repo initialization to the API Move the method for initializing a repository by its path to the shared API module, so that the server can have this as well --- crates/but-api/src/commands/projects.rs | 12 ++++++++++++ crates/but-server/src/lib.rs | 1 + crates/gitbutler-tauri/src/main.rs | 2 +- crates/gitbutler-tauri/src/projects.rs | 10 ---------- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/but-api/src/commands/projects.rs b/crates/but-api/src/commands/projects.rs index f61728a1cc..67e2d3e888 100644 --- a/crates/but-api/src/commands/projects.rs +++ b/crates/but-api/src/commands/projects.rs @@ -1,4 +1,5 @@ use crate::error::Error; +use anyhow::Context; use but_api_macros::api_cmd; use gitbutler_project::{self as projects, ProjectId}; use std::path::PathBuf; @@ -40,3 +41,14 @@ pub fn get_project( pub fn delete_project(project_id: ProjectId) -> Result<(), Error> { gitbutler_project::delete(project_id).map_err(Into::into) } + +/// Initialize a Git repository at the given path +#[api_cmd] +#[tauri::command(async)] +#[instrument(err(Debug))] +pub fn init_git_repository(path: String) -> Result<(), Error> { + let path: PathBuf = path.into(); + git2::Repository::init(&path) + .with_context(|| format!("Failed to initialize Git repository at {}", path.display()))?; + Ok(()) +} diff --git a/crates/but-server/src/lib.rs b/crates/but-server/src/lib.rs index b28fc8e33f..abff76bcb6 100644 --- a/crates/but-server/src/lib.rs +++ b/crates/but-server/src/lib.rs @@ -233,6 +233,7 @@ async fn handle_command( "add_project" => iprojects::add_project_cmd(request.params), "get_project" => iprojects::get_project_cmd(request.params), "delete_project" => iprojects::delete_project_cmd(request.params), + "init_git_repository" => iprojects::init_git_repository_cmd(request.params), "list_projects" => projects::list_projects(&extra).await, "set_project_active" => { projects::set_project_active(&app, &extra, app_settings_sync, request.params).await diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index aee79e9640..9d1ac51445 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -224,10 +224,10 @@ fn main() { but_api::projects::get_project, but_api::projects::update_project, but_api::projects::delete_project, + but_api::projects::init_git_repository, projects::list_projects, projects::set_project_active, projects::open_project_in_window, - projects::init_git_repository, repo::git_get_local_config, repo::git_set_local_config, repo::check_signing_settings, diff --git a/crates/gitbutler-tauri/src/projects.rs b/crates/gitbutler-tauri/src/projects.rs index 5e600abbe1..30ca6dfe41 100644 --- a/crates/gitbutler-tauri/src/projects.rs +++ b/crates/gitbutler-tauri/src/projects.rs @@ -212,13 +212,3 @@ Ensure these aren't touched by GitButler or avoid using it in this repository.", } Ok(Some(msg)) } - -/// Initialize a Git repository at the given path -#[tauri::command] -#[instrument(err(Debug))] -pub fn init_git_repository(path: String) -> Result<(), Error> { - let path: PathBuf = path.into(); - git2::Repository::init(&path) - .with_context(|| format!("Failed to initialize Git repository at {}", path.display()))?; - Ok(()) -} From c15f6794c3c8ac55b535e7e3b8b72c1fb6c84f3a Mon Sep 17 00:00:00 2001 From: estib Date: Tue, 30 Sep 2025 13:57:42 +0200 Subject: [PATCH 5/6] Instrument and update the E2E test for adding a non-git repostory When adding a non-git repostory and then chossing to initialize that, the application should correcly add the project --- apps/desktop/src/lib/project/project.ts | 2 ++ e2e/playwright/tests/addingAProject.spec.ts | 6 ++++++ packages/ui/src/lib/utils/testIds.ts | 1 + 3 files changed, 9 insertions(+) diff --git a/apps/desktop/src/lib/project/project.ts b/apps/desktop/src/lib/project/project.ts index 47ed1debb8..f04459f1b2 100644 --- a/apps/desktop/src/lib/project/project.ts +++ b/apps/desktop/src/lib/project/project.ts @@ -166,11 +166,13 @@ export function handleAddProjectOutcome( return true; case 'notAGitRepository': showToast({ + testId: TestId.AddProjectNotAGitRepoModal, title: 'Not a Git repository', message: 'The selected directory is not a Git repository. Would you like to initialize one?', style: 'warning', extraAction: { + testId: TestId.AddProjectNotAGitRepoModalInitializeButton, label: 'Initialize Repository', onClick: async (dismiss) => { const projectPath = outcome.subject.path; diff --git a/e2e/playwright/tests/addingAProject.spec.ts b/e2e/playwright/tests/addingAProject.spec.ts index 02381d6f6a..d11302a7aa 100644 --- a/e2e/playwright/tests/addingAProject.spec.ts +++ b/e2e/playwright/tests/addingAProject.spec.ts @@ -96,6 +96,12 @@ test('should handle gracefully adding a non-git directory', async ({ page, conte await fileChooser.setFiles(projectPath); await waitForTestId(page, 'add-project-not-a-git-repo-modal'); + // Click it in order to close the select dropdown behind + await clickByTestId(page, 'add-project-not-a-git-repo-modal', true); + + await clickByTestId(page, 'add-project-not-a-git-repo-modal-initialize-button'); + + await waitForTestId(page, 'workspace-view'); }); test('should handle adding a project with extra commit and uncommitted changes on main branch', async ({ diff --git a/packages/ui/src/lib/utils/testIds.ts b/packages/ui/src/lib/utils/testIds.ts index a265f2a8ab..cd64df21cf 100644 --- a/packages/ui/src/lib/utils/testIds.ts +++ b/packages/ui/src/lib/utils/testIds.ts @@ -137,6 +137,7 @@ export enum TestId { AddProjectBareRepoModal = 'add-project-bare-repo-modal', AddProjectNoDotGitDirectoryModal = 'add-project-no-dot-git-directory-modal', AddProjectNotAGitRepoModal = 'add-project-not-a-git-repo-modal', + AddProjectNotAGitRepoModalInitializeButton = 'add-project-not-a-git-repo-modal-initialize-button', ChromeHeaderProjectSelector = 'chrome-header-project-selector', ChromeHeaderProjectSelectorAddLocalProject = 'chrome-header-project-selector-add-local-project', ChromeSideBarProjectSettingsButton = 'chrome-sidebar-project-settings-button', From f36d7bdcc0fa48a1535c62083d54b63bd531d312 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Tue, 30 Sep 2025 14:13:36 +0200 Subject: [PATCH 6/6] Respond to the right error when opening a non-git repository to avoid false positives. --- crates/gitbutler-project/src/controller.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gitbutler-project/src/controller.rs b/crates/gitbutler-project/src/controller.rs index f12281f6fd..5296648b24 100644 --- a/crates/gitbutler-project/src/controller.rs +++ b/crates/gitbutler-project/src/controller.rs @@ -99,7 +99,7 @@ impl Controller { } } }, - Err(err) => { + Err(err @ gix::open::Error::NotARepository { .. }) => { return Ok(AddProjectOutcome::NotAGitRepository( NotAGitRepositoryOutcome { path: path.to_path_buf(), @@ -107,6 +107,7 @@ impl Controller { }, )); } + Err(err) => return Err(err.into()), } let id = ProjectId::generate();