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 33a1c12871..f04459f1b2 100644
--- a/apps/desktop/src/lib/project/project.ts
+++ b/apps/desktop/src/lib/project/project.ts
@@ -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) {
@@ -163,9 +167,19 @@ export function handleAddProjectOutcome(
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: {
+ testId: TestId.AddProjectNotAGitRepoModalInitializeButton,
+ label: 'Initialize Repository',
+ onClick: async (dismiss) => {
+ const projectPath = outcome.subject.path;
+ await onInitialize(projectPath);
+ dismiss();
+ }
+ }
});
return true;
}
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/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/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-project/src/controller.rs b/crates/gitbutler-project/src/controller.rs
index efaf77ecaa..5296648b24 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 {
@@ -96,9 +99,15 @@ impl Controller {
}
}
},
- Err(err) => {
- return Ok(AddProjectOutcome::NotAGitRepository(err.to_string()));
+ Err(err @ gix::open::Error::NotARepository { .. }) => {
+ return Ok(AddProjectOutcome::NotAGitRepository(
+ NotAGitRepositoryOutcome {
+ path: path.to_path_buf(),
+ message: err.to_string(),
+ },
+ ));
}
+ Err(err) => return Err(err.into()),
}
let id = ProjectId::generate();
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))
}
}
}
diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs
index 6ed36524e6..9d1ac51445 100644
--- a/crates/gitbutler-tauri/src/main.rs
+++ b/crates/gitbutler-tauri/src/main.rs
@@ -224,6 +224,7 @@ 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,
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',