Refactor pr templates to use front end persisted store

This commit is contained in:
Mattias Granlund 2024-10-31 18:35:59 +01:00
parent e7e8ffdec2
commit f2878d8915
20 changed files with 152 additions and 246 deletions

View File

@ -1,24 +0,0 @@
import { invoke } from './ipc';
export type ForgeType = 'github' | 'gitlab' | 'bitbucket' | 'azure';
export class ForgeService {
constructor(private projectId: string) {}
async getAvailableReviewTemplates(): Promise<string[]> {
const templates = await invoke<string[]>('get_available_review_templates', {
projectId: this.projectId
});
return templates;
}
async getReviewTemplateContent(templatePath: string): Promise<string> {
const fileContents: string = await invoke('get_review_template_contents', {
relativePath: templatePath,
projectId: this.projectId
});
return fileContents;
}
}

View File

@ -5,7 +5,6 @@ import { persisted } from '@gitbutler/shared/persisted';
import { open } from '@tauri-apps/plugin-dialog';
import { plainToInstance } from 'class-transformer';
import { derived, get, writable, type Readable } from 'svelte/store';
import type { ForgeType } from './forge';
import type { HttpClient } from '@gitbutler/shared/httpClient';
import { goto } from '$app/navigation';
@ -16,8 +15,6 @@ export type LocalKey = {
export type Key = Exclude<KeyType, 'local'> | LocalKey;
export type HostType = { type: ForgeType };
export class Project {
id!: string;
title!: string;
@ -30,11 +27,6 @@ export class Project {
use_diff_context: boolean | undefined;
snapshot_lines_threshold!: number | undefined;
use_experimental_locking!: boolean;
git_host!: {
hostType: HostType | undefined;
reviewTemplatePath: string | undefined;
};
// Produced just for the frontend to determine if the project is open in any window.
is_open!: boolean;
@ -220,14 +212,6 @@ export class ProjectsService {
async getCloudProject(repositoryId: string): Promise<CloudProject> {
return await this.httpClient.get(`projects/${repositoryId}.json`);
}
async setForgeType(project: Project, type: ForgeType) {
if (project.git_host.hostType?.type === type) return;
const hostType: HostType = { type };
const forge = { hostType };
await invoke('update_project_git_host', { projectId: project.id, forge });
this.reload();
}
}
/**

View File

@ -0,0 +1,20 @@
import { invoke } from './ipc';
export class TemplateService {
constructor(private projectId: string) {}
async getAvailable(forgeName: string): Promise<string[]> {
return await invoke<string[]>('get_available_review_templates', {
projectId: this.projectId,
forge: { name: forgeName }
});
}
async getContent(forgeName: string, templatePath: string): Promise<string> {
return await invoke('get_review_template_contents', {
relativePath: templatePath,
projectId: this.projectId,
forge: { name: forgeName }
});
}
}

View File

@ -1,7 +1,6 @@
import { AzureBranch } from './azureBranch';
import type { ForgeType } from '$lib/backend/forge';
import type { RepoInfo } from '$lib/url/gitUrl';
import type { Forge } from '../interface/forge';
import type { Forge, ForgeName } from '../interface/forge';
import type { ForgeArguments } from '../interface/types';
export const AZURE_DOMAIN = 'dev.azure.com';
@ -13,7 +12,7 @@ export const AZURE_DOMAIN = 'dev.azure.com';
* https://github.com/gitbutlerapp/gitbutler/issues/2651
*/
export class AzureDevOps implements Forge {
readonly type: ForgeType = 'azure';
readonly name: ForgeName = 'azure';
private baseUrl: string;
private repo: RepoInfo;
private baseBranch: string;

View File

@ -1,7 +1,6 @@
import { BitBucketBranch } from './bitbucketBranch';
import type { ForgeType } from '$lib/backend/forge';
import type { RepoInfo } from '$lib/url/gitUrl';
import type { Forge } from '../interface/forge';
import type { Forge, ForgeName } from '../interface/forge';
import type { DetailedPullRequest, ForgeArguments } from '../interface/types';
export type PrAction = 'creating_pr';
@ -17,7 +16,7 @@ export const BITBUCKET_DOMAIN = 'bitbucket.org';
* https://github.com/gitbutlerapp/gitbutler/issues/3252
*/
export class BitBucket implements Forge {
readonly type: ForgeType = 'bitbucket';
readonly name: ForgeName = 'bitbucket';
private baseUrl: string;
private repo: RepoInfo;
private baseBranch: string;

View File

@ -4,16 +4,15 @@ import { GitHubListingService } from './githubListingService';
import { GitHubPrService } from './githubPrService';
import { GitHubIssueService } from '$lib/forge/github/issueService';
import { Octokit } from '@octokit/rest';
import type { ForgeType } from '$lib/backend/forge';
import type { ProjectMetrics } from '$lib/metrics/projectMetrics';
import type { RepoInfo } from '$lib/url/gitUrl';
import type { Forge } from '../interface/forge';
import type { Forge, ForgeName } from '../interface/forge';
import type { ForgeArguments } from '../interface/types';
export const GITHUB_DOMAIN = 'github.com';
export class GitHub implements Forge {
readonly type: ForgeType = 'github';
readonly name: ForgeName = 'github';
private baseUrl: string;
private repo: RepoInfo;
private baseBranch: string;

View File

@ -1,7 +1,6 @@
import { GitLabBranch } from './gitlabBranch';
import type { ForgeType } from '$lib/backend/forge';
import type { RepoInfo } from '$lib/url/gitUrl';
import type { Forge } from '../interface/forge';
import type { Forge, ForgeName } from '../interface/forge';
import type { DetailedPullRequest, ForgeArguments } from '../interface/types';
export type PrAction = 'creating_pr';
@ -18,7 +17,7 @@ export const GITLAB_SUB_DOMAIN = 'gitlab'; // For self hosted instance of Gitlab
* https://github.com/gitbutlerapp/gitbutler/issues/2511
*/
export class GitLab implements Forge {
readonly type: ForgeType = 'gitlab';
readonly name: ForgeName = 'gitlab';
private baseUrl: string;
private repo: RepoInfo;
private baseBranch: string;

View File

@ -1,13 +1,14 @@
import { buildContextStore } from '@gitbutler/shared/context';
import type { ForgeType } from '$lib/backend/forge';
import type { ForgeIssueService } from '$lib/forge/interface/forgeIssueService';
import type { ForgeBranch } from './forgeBranch';
import type { ForgeChecksMonitor } from './forgeChecksMonitor';
import type { ForgeListingService } from './forgeListingService';
import type { ForgePrService } from './forgePrService';
export type ForgeName = 'github' | 'gitlab' | 'bitbucket' | 'azure';
export interface Forge {
readonly type: ForgeType;
readonly name: ForgeName;
// Lists PRs for the repo.
listService(): ForgeListingService | undefined;

View File

@ -12,6 +12,7 @@
import { getPreferredPRAction, PRAction } from './pr';
import { AIService } from '$lib/ai/service';
import { Project } from '$lib/backend/projects';
import { TemplateService } from '$lib/backend/templateService';
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import Markdown from '$lib/components/Markdown.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
@ -32,6 +33,7 @@
import { BranchController } from '$lib/vbranches/branchController';
import { PatchSeries, VirtualBranch } from '$lib/vbranches/types';
import { getContext, getContextStore } from '@gitbutler/shared/context';
import { persisted } from '@gitbutler/shared/persisted';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Textarea from '@gitbutler/ui/Textarea.svelte';
@ -71,6 +73,7 @@
const aiService = getContext(AIService);
const aiGenEnabled = projectAiGenEnabled(project.id);
const forge = getForge();
const templateService = getContext(TemplateService);
const preferredPRAction = getPreferredPRAction();
const branch = $derived($branchStore);
@ -99,10 +102,16 @@
let showAiBox = $state<boolean>(false);
let pushBeforeCreate = $state(false);
// Displays template select component when true.
let useTemplate = persisted(false, `use-template-${project.id}`);
// Available pull request templates.
let templates = $state<string[]>([]);
async function handleToggleUseTemplate() {
if (!templateSelector) return;
const displaying = templateSelector.imports.showing;
await templateSelector.setUsePullRequestTemplate(!displaying);
useTemplate.set(!$useTemplate);
if (!$useTemplate) {
pullRequestTemplateBody = undefined;
}
}
const canUseAI = $derived.by(() => {
@ -145,6 +154,11 @@
aiService.validateConfiguration().then((valid) => {
aiConfigurationValid = valid;
});
if ($forge) {
templateService.getAvailable($forge.name).then((availableTemplates) => {
templates = availableTemplates;
});
}
}
});
@ -363,9 +377,9 @@
<ToggleButton
icon="doc"
label="Use PR template"
checked={!!templateSelector?.imports.showing}
checked={$useTemplate}
onclick={handleToggleUseTemplate}
disabled={!templateSelector?.imports.hasTemplates}
disabled={templates.length === 0}
/>
<ToggleButton
icon="ai-small"
@ -380,7 +394,13 @@
</div>
<!-- PR TEMPLATE SELECT -->
<PrTemplateSection bind:this={templateSelector} bind:pullRequestTemplateBody />
{#if $useTemplate}
<PrTemplateSection
bind:this={templateSelector}
onselected={(body) => (pullRequestTemplateBody = body)}
{templates}
/>
{/if}
<!-- DESCRIPTION FIELD -->
<div class="pr-description-field text-input">

View File

@ -1,106 +1,75 @@
<script lang="ts">
import { ForgeService } from '$lib/backend/forge';
import { ProjectService, ProjectsService } from '$lib/backend/projects';
import { Project } from '$lib/backend/projects';
import { TemplateService } from '$lib/backend/templateService';
import { getForge } from '$lib/forge/interface/forge';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import { getContext } from '@gitbutler/shared/context';
import { persisted } from '@gitbutler/shared/persisted';
import { onMount } from 'svelte';
interface Props {
pullRequestTemplateBody: string | undefined;
templates: string[];
onselected: (body: string) => void;
}
let { pullRequestTemplateBody = $bindable() }: Props = $props();
let { templates, onselected }: Props = $props();
const projectsService = getContext(ProjectsService);
const projectService = getContext(ProjectService);
const forgeService = getContext(ForgeService);
const forge = getForge();
// TODO: Rename or refactor this service.
const templateService = getContext(TemplateService);
const project = getContext(Project);
let allAvailableTemplates = $state<{ label: string; value: string }[]>([]);
// The last template that was used. It is used as default if it is in the
// list of available commits.
const lastTemplate = persisted<string | undefined>(undefined, `last-template-${project.id}`);
const projectStore = projectService.project;
const project = $derived($projectStore);
const reviewTemplatePath = $derived(project?.git_host.reviewTemplatePath);
const show = $derived(!!reviewTemplatePath);
async function setTemplate(path: string) {
if ($forge) {
lastTemplate.set(path);
loadAndEmit(path);
}
}
// Fetch PR template content
$effect(() => {
if (!project) return;
if (reviewTemplatePath) {
forgeService.getReviewTemplateContent(reviewTemplatePath).then((template) => {
pullRequestTemplateBody = template;
});
async function loadAndEmit(path: string) {
if (path && $forge) {
const template = await templateService.getContent($forge.name, path);
if (template) {
onselected(template);
}
}
}
onMount(() => {
if ($lastTemplate && templates.includes($lastTemplate)) {
loadAndEmit($lastTemplate);
} else if (templates.length === 1) {
const path = templates.at(0);
if (path) {
loadAndEmit(path);
lastTemplate.set(path);
}
}
});
// Fetch available PR templates
$effect(() => {
if (!project) return;
forgeService.getAvailableReviewTemplates().then((availableTemplates) => {
if (availableTemplates) {
allAvailableTemplates = availableTemplates.map((availableTemplate) => {
return {
label: availableTemplate,
value: availableTemplate
};
});
}
});
});
async function setPullRequestTemplatePath(value: string) {
if (!project) return;
project.git_host.reviewTemplatePath = value;
await projectsService.updateProject(project);
}
export async function setUsePullRequestTemplate(value: boolean) {
if (!project) return;
setTemplate: {
if (!value) {
project.git_host.reviewTemplatePath = undefined;
pullRequestTemplateBody = undefined;
break setTemplate;
}
if (allAvailableTemplates[0]) {
project.git_host.reviewTemplatePath = allAvailableTemplates[0].value;
break setTemplate;
}
}
await projectsService.updateProject(project);
}
export const imports = {
get showing() {
return show;
},
get hasTemplates() {
return allAvailableTemplates.length > 0;
}
};
</script>
{#if show}
<div class="pr-template__wrap">
<Select
value={reviewTemplatePath}
options={allAvailableTemplates.map(({ label, value }) => ({ label, value }))}
placeholder="No PR templates found ¯\_(ツ)_/¯"
flex="1"
searchable
disabled={allAvailableTemplates.length <= 1}
onselect={setPullRequestTemplatePath}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === reviewTemplatePath} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
</div>
{/if}
<div class="pr-template__wrap">
<Select
value={$lastTemplate}
options={templates.map((value) => ({ label: value, value }))}
placeholder={templates.length > 0 ? 'Choose template' : 'No PR templates found ¯_(ツ)_/¯'}
flex="1"
searchable
disabled={templates.length === 0}
onselect={setTemplate}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === $lastTemplate} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
</div>
<style lang="postcss">
.pr-template__wrap {

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { ForgeService } from '$lib/backend/forge';
import { Project, ProjectService } from '$lib/backend/projects';
import { TemplateService } from '$lib/backend/templateService';
import FileMenuAction from '$lib/barmenuActions/FileMenuAction.svelte';
import ProjectSettingsMenuAction from '$lib/barmenuActions/ProjectSettingsMenuAction.svelte';
import { BaseBranch, NoDefaultTarget } from '$lib/baseBranch/baseBranch';
@ -68,7 +68,7 @@
setContext(BranchController, data.branchController);
setContext(BaseBranchService, data.baseBranchService);
setContext(CommitService, data.commitService);
setContext(ForgeService, data.forgeService);
setContext(TemplateService, data.templateService);
setContext(BaseBranch, baseBranch);
setContext(Project, project);
setContext(BranchDragActionsFactory, data.branchDragActionsFactory);
@ -140,11 +140,7 @@
repoInfo && baseBranchName
? forgeFactory.build(repoInfo, baseBranchName, forkInfo)
: undefined;
const ghListService = forge?.listService();
if (forge) projectsService.setForgeType(project, forge.type);
listServiceStore.set(ghListService);
forgeStore.set(forge);
});

View File

@ -1,6 +1,6 @@
import { ForgeService } from '$lib/backend/forge';
import { getUserErrorCode, invoke } from '$lib/backend/ipc';
import { ProjectService, type Project } from '$lib/backend/projects';
import { TemplateService } from '$lib/backend/templateService';
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import { CloudBranchCreationService } from '$lib/branch/cloudBranchCreationService';
import { BranchListingService } from '$lib/branches/branchListing';
@ -59,7 +59,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
const historyService = new HistoryService(projectId);
const baseBranchService = new BaseBranchService(projectId);
const commitService = new CommitService(projectId);
const forgeService = new ForgeService(projectId);
const templateService = new TemplateService(projectId);
const branchListingService = new BranchListingService(projectId);
const remoteBranchService = new RemoteBranchService(
@ -107,7 +107,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
authService,
baseBranchService,
commitService,
forgeService,
templateService,
branchController,
historyService,
projectId,

View File

@ -1,9 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
#[serde(tag = "type", rename_all = "lowercase")]
#[serde(tag = "name", rename_all = "lowercase")]
/// Supported git forge types
pub enum ForgeType {
pub enum ForgeName {
GitHub,
GitLab,
Bitbucket,

View File

@ -2,17 +2,19 @@ use std::path::{self, Path};
use gitbutler_fs::list_files;
use crate::forge::ForgeType;
use crate::forge::ForgeName;
/// Get a list of available review template paths for a project
///
/// The paths are relative to the root path
pub fn available_review_templates(root_path: &path::Path, forge_type: &ForgeType) -> Vec<String> {
pub fn available_review_templates(root_path: &path::Path, forge_name: &ForgeName) -> Vec<String> {
dbg!(&forge_name);
dbg!(&root_path);
let ReviewTemplateFunctions {
is_review_template,
get_root,
..
} = get_review_template_functions(forge_type);
} = get_review_template_functions(forge_name);
let forge_root_path = get_root(root_path);
let forge_root_path = forge_root_path.as_path();
@ -46,24 +48,24 @@ pub struct ReviewTemplateFunctions {
pub is_valid_review_template_path: fn(&path::Path, &path::Path) -> bool,
}
pub fn get_review_template_functions(forge_type: &ForgeType) -> ReviewTemplateFunctions {
match forge_type {
ForgeType::GitHub => ReviewTemplateFunctions {
pub fn get_review_template_functions(forge_name: &ForgeName) -> ReviewTemplateFunctions {
match forge_name {
ForgeName::GitHub => ReviewTemplateFunctions {
is_review_template: is_review_template_github,
get_root: get_github_directory_path,
is_valid_review_template_path: is_valid_review_template_path_github,
},
ForgeType::GitLab => ReviewTemplateFunctions {
ForgeName::GitLab => ReviewTemplateFunctions {
is_review_template: is_review_template_gitlab,
get_root: get_gitlab_directory_path,
is_valid_review_template_path: is_valid_review_template_path_gitlab,
},
ForgeType::Bitbucket => ReviewTemplateFunctions {
ForgeName::Bitbucket => ReviewTemplateFunctions {
is_review_template: is_review_template_bitbucket,
get_root: get_bitbucket_directory_path,
is_valid_review_template_path: is_valid_review_template_path_bitbucket,
},
ForgeType::Azure => ReviewTemplateFunctions {
ForgeName::Azure => ReviewTemplateFunctions {
is_review_template: is_review_template_azure,
get_root: get_azure_directory_path,
is_valid_review_template_path: is_valid_review_template_path_azure,

View File

@ -5,9 +5,7 @@ mod project;
mod storage;
pub use controller::Controller;
pub use project::{
ApiProject, AuthKey, CodePushState, FetchResult, ForgeSettings, Project, ProjectId,
};
pub use project::{ApiProject, AuthKey, CodePushState, FetchResult, Project, ProjectId};
pub use storage::UpdateRequest;
/// A utility to be used from applications to optimize `git2` configuration.

View File

@ -1,9 +1,8 @@
use std::{
path::{self, Path, PathBuf},
path::{self, PathBuf},
time,
};
use gitbutler_forge::{forge::ForgeType, review::available_review_templates};
use gitbutler_id::id::Id;
use serde::{Deserialize, Serialize};
@ -95,8 +94,6 @@ pub struct Project {
pub omit_certificate_check: Option<bool>,
// The number of changed lines that will trigger a snapshot
pub snapshot_lines_threshold: Option<usize>,
#[serde(default)]
pub git_host: ForgeSettings,
// Experimental flag for new hunk dependency algorithm
#[serde(default = "default_true")]
pub use_experimental_locking: bool,
@ -107,27 +104,6 @@ fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct ForgeSettings {
#[serde(default)]
pub host_type: Option<ForgeType>,
#[serde(default)]
pub review_template_path: Option<String>,
}
impl ForgeSettings {
pub fn init(&mut self, project_path: &Path) {
if let Some(forge_type) = &self.host_type {
if self.review_template_path.is_none() {
self.review_template_path = available_review_templates(project_path, forge_type)
.first()
.cloned();
}
}
}
}
impl Project {
/// Determines if the project Operations log will be synched with the GitButHub
pub fn oplog_sync_enabled(&self) -> bool {

View File

@ -3,7 +3,7 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::{ApiProject, AuthKey, CodePushState, FetchResult, ForgeSettings, Project, ProjectId};
use crate::{ApiProject, AuthKey, CodePushState, FetchResult, Project, ProjectId};
const PROJECTS_FILE: &str = "projects.json";
@ -27,7 +27,6 @@ pub struct UpdateRequest {
pub omit_certificate_check: Option<bool>,
pub use_diff_context: Option<bool>,
pub snapshot_lines_threshold: Option<usize>,
pub git_host: Option<ForgeSettings>,
pub use_experimental_locking: Option<bool>,
}
@ -125,10 +124,6 @@ impl Storage {
project.snapshot_lines_threshold = Some(snapshot_lines_threshold);
}
if let Some(git_host) = &update_request.git_host {
project.git_host = git_host.clone();
}
if let Some(use_experimental_locking) = &update_request.use_experimental_locking {
project.use_experimental_locking = *use_experimental_locking;
}

View File

@ -2,8 +2,11 @@ pub mod commands {
use std::path::Path;
use anyhow::Context;
use gitbutler_forge::review::{
available_review_templates, get_review_template_functions, ReviewTemplateFunctions,
use gitbutler_forge::{
forge::ForgeName,
review::{
available_review_templates, get_review_template_functions, ReviewTemplateFunctions,
},
};
use gitbutler_project::{Controller, ProjectId};
use gitbutler_repo::RepoCommands;
@ -17,15 +20,10 @@ pub mod commands {
pub fn get_available_review_templates(
projects: State<'_, Controller>,
project_id: ProjectId,
forge: ForgeName,
) -> Result<Vec<String>, Error> {
let project = projects.get_validated(project_id)?;
let root_path = &project.path;
let forge_type = project.git_host.host_type;
let review_templates = forge_type
.map(|forge_type| available_review_templates(root_path, &forge_type))
.unwrap_or_default();
Ok(review_templates)
Ok(available_review_templates(&project.path, &forge))
}
#[tauri::command(async)]
@ -34,26 +32,24 @@ pub mod commands {
projects: State<'_, Controller>,
project_id: ProjectId,
relative_path: &Path,
) -> Result<String, Error> {
forge: ForgeName,
) -> anyhow::Result<String, Error> {
let project = projects.get_validated(project_id)?;
let forge_type = project
.git_host
.host_type
.clone()
.context("Project does not have a forge type")?;
let ReviewTemplateFunctions {
is_valid_review_template_path,
..
} = get_review_template_functions(&forge_type);
} = get_review_template_functions(&forge);
if !is_valid_review_template_path(relative_path, &project.path) {
return Err(anyhow::anyhow!("Invalid review template path").into());
return Err(anyhow::format_err!(
"Invalid review template path: {:?}",
Path::join(&project.path, relative_path)
)
.into());
}
let file_info = project.read_file_from_workspace(None, relative_path)?;
Ok(file_info
Ok(project
.read_file_from_workspace(None, relative_path)?
.content
.context("PR template was not valid UTF-8")?)
}

View File

@ -151,7 +151,6 @@ fn main() {
projects::commands::list_projects,
projects::commands::set_project_active,
projects::commands::open_project_in_window,
projects::commands::update_project_git_host,
repo::commands::git_get_local_config,
repo::commands::git_set_local_config,
repo::commands::check_signing_settings,

View File

@ -4,9 +4,7 @@ pub mod commands {
use std::path;
use anyhow::Context;
use gitbutler_project::{
self as projects, Controller, ForgeSettings, ProjectId, UpdateRequest,
};
use gitbutler_project::{self as projects, Controller, ProjectId};
use tauri::{State, Window};
use tracing::instrument;
@ -21,26 +19,6 @@ pub mod commands {
Ok(projects.update(&project)?)
}
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn update_project_git_host(
projects: State<'_, Controller>,
project_id: ProjectId,
git_host: ForgeSettings,
) -> Result<projects::Project, Error> {
let project = projects.get_validated(project_id)?;
let root_path = &project.path;
let mut git_host = git_host.clone();
git_host.init(root_path);
let request = UpdateRequest {
id: project_id,
git_host: Some(git_host),
..Default::default()
};
Ok(projects.update(&request)?)
}
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn add_project(