Git support (#143)

This commit is contained in:
Gregory Schier
2025-02-07 07:59:48 -08:00
committed by GitHub
parent cffc7714c1
commit 1a7c27663a
111 changed files with 4264 additions and 372 deletions

92
src-tauri/Cargo.lock generated
View File

@@ -730,6 +730,10 @@ name = "cc"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "504bdec147f2cc13c8b57ed9401fd8a147cc66b67ad5cb241394244f2c947549"
dependencies = [
"jobserver",
"libc",
]
[[package]]
name = "cesu8"
@@ -1986,6 +1990,21 @@ dependencies = [
"winapi",
]
[[package]]
name = "git2"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fda788993cc341f69012feba8bf45c0ba4f3291fcc08e214b4d5a7332d88aff"
dependencies = [
"bitflags 2.6.0",
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
[[package]]
name = "glib"
version = "0.18.5"
@@ -2611,6 +2630,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
dependencies = [
"libc",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.1"
@@ -2731,6 +2759,20 @@ version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "libgit2-sys"
version = "0.18.0+1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1a117465e7e1597e8febea8bb0c410f1c7fb93b1e1cddf34363f8390367ffec"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.7.4"
@@ -2779,6 +2821,32 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libssh2-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9b68e50e6e0b26f672573834882eb57759f6db9b3be2ea3c35c91188bb4eaa"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
@@ -3486,9 +3554,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
version = "0.9.103"
version = "0.9.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc"
dependencies = [
"cc",
"libc",
@@ -7620,6 +7688,7 @@ dependencies = [
"tokio-stream",
"ts-rs",
"uuid",
"yaak-git",
"yaak-grpc",
"yaak-http",
"yaak-license",
@@ -7631,6 +7700,25 @@ dependencies = [
"yaak-ws",
]
[[package]]
name = "yaak-git"
version = "0.1.0"
dependencies = [
"chrono",
"git2",
"log",
"openssl-sys",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-plugin",
"thiserror 2.0.11",
"ts-rs",
"yaak-models",
"yaak-sync",
]
[[package]]
name = "yaak-grpc"
version = "0.1.0"

View File

@@ -1,5 +1,6 @@
[workspace]
members = [
"yaak-git",
"yaak-grpc",
"yaak-http",
"yaak-license",
@@ -37,7 +38,7 @@ objc = "0.2.7"
cocoa = "0.26.0"
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9", features = ["vendored"] } # For Ubuntu installation to work
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[dependencies]
chrono = { version = "0.4.31", features = ["serde"] }
@@ -72,13 +73,14 @@ tokio = { version = "1.43.0", features = ["sync"] }
tokio-stream = "0.1.17"
ts-rs = { workspace = true }
uuid = "1.12.1"
yaak-git = { path = "yaak-git" }
yaak-grpc = { path = "yaak-grpc" }
yaak-http = { workspace = true }
yaak-license = { path = "yaak-license" }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
yaak-sync = { path = "yaak-sync" }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-ws = { path = "yaak-ws" }
@@ -91,8 +93,9 @@ tauri-plugin = "2.0.4"
tauri-plugin-shell = "2.2.0"
thiserror = "2.0.3"
ts-rs = "10.0.0"
yaak-http = { path = "yaak-http" }
yaak-models = { path = "yaak-models" }
yaak-plugins = { path = "yaak-plugins" }
yaak-http = { path = "yaak-http" }
yaak-sync = { path = "yaak-sync" }
yaak-sse = { path = "yaak-sse" }
yaak-templates = { path = "yaak-templates" }

View File

@@ -51,6 +51,7 @@
"opener:allow-reveal-item-in-dir",
"shell:allow-open",
"yaak-license:default",
"yaak-git:default",
"yaak-sync:default",
"yaak-ws:default"
]

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-dir","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-internal-toggle-maximize","core:window:allow-is-fullscreen","core:window:allow-is-maximized","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-unmaximize","opener:allow-default-urls","opener:allow-open-path","opener:allow-open-url","opener:allow-reveal-item-in-dir","shell:allow-open","yaak-license:default","yaak-sync:default","yaak-ws:default"]}}
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-dir","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-internal-toggle-maximize","core:window:allow-is-fullscreen","core:window:allow-is-maximized","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-unmaximize","opener:allow-default-urls","opener:allow-open-path","opener:allow-open-url","opener:allow-reveal-item-in-dir","shell:allow-open","yaak-license:default","yaak-git:default","yaak-sync:default","yaak-ws:default"]}}

View File

@@ -5412,6 +5412,131 @@
"type": "string",
"const": "window-state:deny-save-window-state"
},
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "yaak-git:default"
},
{
"description": "Enables the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-add"
},
{
"description": "Enables the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-branch"
},
{
"description": "Enables the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-checkout"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-commit"
},
{
"description": "Enables the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-delete-branch"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-initialize"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-log"
},
{
"description": "Enables the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-merge-branch"
},
{
"description": "Enables the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-pull"
},
{
"description": "Enables the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-push"
},
{
"description": "Enables the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-status"
},
{
"description": "Enables the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-unstage"
},
{
"description": "Denies the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-add"
},
{
"description": "Denies the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-branch"
},
{
"description": "Denies the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-checkout"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-commit"
},
{
"description": "Denies the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-delete-branch"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-initialize"
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-log"
},
{
"description": "Denies the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-merge-branch"
},
{
"description": "Denies the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-pull"
},
{
"description": "Denies the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-push"
},
{
"description": "Denies the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-status"
},
{
"description": "Denies the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-unstage"
},
{
"description": "Default permissions for the plugin",
"type": "string",

View File

@@ -5412,6 +5412,131 @@
"type": "string",
"const": "window-state:deny-save-window-state"
},
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "yaak-git:default"
},
{
"description": "Enables the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-add"
},
{
"description": "Enables the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-branch"
},
{
"description": "Enables the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-checkout"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-commit"
},
{
"description": "Enables the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-delete-branch"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-initialize"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-log"
},
{
"description": "Enables the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-merge-branch"
},
{
"description": "Enables the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-pull"
},
{
"description": "Enables the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-push"
},
{
"description": "Enables the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-status"
},
{
"description": "Enables the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-unstage"
},
{
"description": "Denies the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-add"
},
{
"description": "Denies the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-branch"
},
{
"description": "Denies the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-checkout"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-commit"
},
{
"description": "Denies the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-delete-branch"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-initialize"
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-log"
},
{
"description": "Denies the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-merge-branch"
},
{
"description": "Denies the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-pull"
},
{
"description": "Denies the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-push"
},
{
"description": "Denies the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-status"
},
{
"description": "Denies the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-unstage"
},
{
"description": "Default permissions for the plugin",
"type": "string",

View File

@@ -0,0 +1,2 @@
-- This setting was moved to the new workspace_metas table
ALTER TABLE workspaces DROP COLUMN setting_sync_dir;

View File

@@ -4,6 +4,7 @@ use log::{debug, info};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tauri::{Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_models::queries::{
generate_id, get_key_value_int, get_key_value_string, get_or_create_settings,

View File

@@ -1819,6 +1819,7 @@ pub fn run() {
.plugin(yaak_license::init())
.plugin(yaak_models::plugin::Builder::default().build())
.plugin(yaak_plugins::init())
.plugin(yaak_git::init())
.plugin(yaak_ws::init())
.plugin(yaak_sync::init());

View File

@@ -0,0 +1,25 @@
[package]
name = "yaak-git"
links = "yaak-git"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
git2 = { version = "0.20.0" }
log = "0.4.22"
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.132"
serde_yaml = "0.9.34"
tauri = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-models = { workspace = true }
yaak-sync = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[build-dependencies]
tauri-plugin = { version = "2.0.3", features = ["build"] }

View File

@@ -0,0 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SyncModel } from "./gen_models";
export type GitAuthor = { name: string | null, email: string | null, };
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
export type GitStatus = "added" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, branches: Array<string>, };
export type PullResult = { receivedBytes: number, receivedObjects: number, };
export type PushResult = "success" | "nothing_to_push";
export type PushType = "branch" | "tag";

View File

@@ -0,0 +1,23 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -0,0 +1,18 @@
const COMMANDS: &[&str] = &[
"add",
"branch",
"checkout",
"commit",
"delete_branch",
"initialize",
"log",
"merge_branch",
"pull",
"push",
"status",
"unstage",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS).build();
}

View File

@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import { GitCommit, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
export * from './bindings/gen_git';
export function useGit(dir: string) {
const queryClient = useQueryClient();
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
return [
{
log: useQuery<void, string, GitCommit[]>({
queryKey: ['git', 'log', dir],
queryFn: () => invoke('plugin:yaak-git|log', { dir }),
}),
status: useQuery<void, string, GitStatusSummary>({
refetchOnMount: true,
queryKey: ['git', 'status', dir],
queryFn: () => invoke('plugin:yaak-git|status', { dir }),
}),
},
{
add: useMutation<void, string, { relaPaths: string[] }>({
mutationKey: ['git', 'add', dir],
mutationFn: (args) => invoke('plugin:yaak-git|add', { dir, ...args }),
onSuccess,
}),
branch: useMutation<void, string, { branch: string }>({
mutationKey: ['git', 'branch', dir],
mutationFn: (args) => invoke('plugin:yaak-git|branch', { dir, ...args }),
onSuccess,
}),
mergeBranch: useMutation<void, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'merge', dir],
mutationFn: (args) => invoke('plugin:yaak-git|merge_branch', { dir, ...args }),
onSuccess,
}),
deleteBranch: useMutation<void, string, { branch: string }>({
mutationKey: ['git', 'delete-branch', dir],
mutationFn: (args) => invoke('plugin:yaak-git|delete_branch', { dir, ...args }),
onSuccess,
}),
checkout: useMutation<void, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'checkout', dir],
mutationFn: (args) => invoke('plugin:yaak-git|checkout', { dir, ...args }),
onSuccess,
}),
commit: useMutation<void, string, { message: string }>({
mutationKey: ['git', 'commit', dir],
mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }),
onSuccess,
}),
push: useMutation<PushResult, string, void>({
mutationKey: ['git', 'push', dir],
mutationFn: () => invoke('plugin:yaak-git|push', { dir }),
onSuccess,
}),
pull: useMutation<PullResult, string, void>({
mutationKey: ['git', 'pull', dir],
mutationFn: () => invoke('plugin:yaak-git|pull', { dir }),
onSuccess,
}),
unstage: useMutation<void, string, { relaPaths: string[] }>({
mutationKey: ['git', 'unstage', dir],
mutationFn: (args) => invoke('plugin:yaak-git|unstage', { dir, ...args }),
onSuccess,
}),
},
] as const;
}
export async function gitInit(dir: string) {
await invoke('plugin:yaak-git|initialize', { dir });
}

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/git",
"private": true,
"version": "1.0.0",
"main": "index.ts"
}

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-add"
description = "Enables the add command without any pre-configured scope."
commands.allow = ["add"]
[[permission]]
identifier = "deny-add"
description = "Denies the add command without any pre-configured scope."
commands.deny = ["add"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-branch"
description = "Enables the branch command without any pre-configured scope."
commands.allow = ["branch"]
[[permission]]
identifier = "deny-branch"
description = "Denies the branch command without any pre-configured scope."
commands.deny = ["branch"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-checkout"
description = "Enables the checkout command without any pre-configured scope."
commands.allow = ["checkout"]
[[permission]]
identifier = "deny-checkout"
description = "Denies the checkout command without any pre-configured scope."
commands.deny = ["checkout"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-commit"
description = "Enables the commit command without any pre-configured scope."
commands.allow = ["commit"]
[[permission]]
identifier = "deny-commit"
description = "Denies the commit command without any pre-configured scope."
commands.deny = ["commit"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-delete-branch"
description = "Enables the delete_branch command without any pre-configured scope."
commands.allow = ["delete_branch"]
[[permission]]
identifier = "deny-delete-branch"
description = "Denies the delete_branch command without any pre-configured scope."
commands.deny = ["delete_branch"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-initialize"
description = "Enables the initialize command without any pre-configured scope."
commands.allow = ["initialize"]
[[permission]]
identifier = "deny-initialize"
description = "Denies the initialize command without any pre-configured scope."
commands.deny = ["initialize"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-log"
description = "Enables the log command without any pre-configured scope."
commands.allow = ["log"]
[[permission]]
identifier = "deny-log"
description = "Denies the log command without any pre-configured scope."
commands.deny = ["log"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-merge-branch"
description = "Enables the merge_branch command without any pre-configured scope."
commands.allow = ["merge_branch"]
[[permission]]
identifier = "deny-merge-branch"
description = "Denies the merge_branch command without any pre-configured scope."
commands.deny = ["merge_branch"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pull"
description = "Enables the pull command without any pre-configured scope."
commands.allow = ["pull"]
[[permission]]
identifier = "deny-pull"
description = "Denies the pull command without any pre-configured scope."
commands.deny = ["pull"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-push"
description = "Enables the push command without any pre-configured scope."
commands.allow = ["push"]
[[permission]]
identifier = "deny-push"
description = "Denies the push command without any pre-configured scope."
commands.deny = ["push"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-status"
description = "Enables the status command without any pre-configured scope."
commands.allow = ["status"]
[[permission]]
identifier = "deny-status"
description = "Denies the status command without any pre-configured scope."
commands.deny = ["status"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-unstage"
description = "Enables the unstage command without any pre-configured scope."
commands.allow = ["unstage"]
[[permission]]
identifier = "deny-unstage"
description = "Denies the unstage command without any pre-configured scope."
commands.deny = ["unstage"]

View File

@@ -0,0 +1,338 @@
## Default Permission
Default permissions for the plugin
- `allow-add`
- `allow-branch`
- `allow-checkout`
- `allow-commit`
- `allow-delete-branch`
- `allow-initialize`
- `allow-log`
- `allow-merge-branch`
- `allow-pull`
- `allow-push`
- `allow-status`
- `allow-unstage`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`yaak-git:allow-add`
</td>
<td>
Enables the add command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-add`
</td>
<td>
Denies the add command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-branch`
</td>
<td>
Enables the branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-branch`
</td>
<td>
Denies the branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-checkout`
</td>
<td>
Enables the checkout command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-checkout`
</td>
<td>
Denies the checkout command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-commit`
</td>
<td>
Enables the commit command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-commit`
</td>
<td>
Denies the commit command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-delete-branch`
</td>
<td>
Enables the delete_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-delete-branch`
</td>
<td>
Denies the delete_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-initialize`
</td>
<td>
Enables the initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-initialize`
</td>
<td>
Denies the initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-log`
</td>
<td>
Enables the log command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-log`
</td>
<td>
Denies the log command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-merge-branch`
</td>
<td>
Enables the merge_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-merge-branch`
</td>
<td>
Denies the merge_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-pull`
</td>
<td>
Enables the pull command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-pull`
</td>
<td>
Denies the pull command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-push`
</td>
<td>
Enables the push command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-push`
</td>
<td>
Denies the push command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-status`
</td>
<td>
Enables the status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-status`
</td>
<td>
Denies the status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-unstage`
</td>
<td>
Enables the unstage command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-unstage`
</td>
<td>
Denies the unstage command without any pre-configured scope.
</td>
</tr>
</table>

View File

@@ -0,0 +1,16 @@
[default]
description = "Default permissions for the plugin"
permissions = [
"allow-add",
"allow-branch",
"allow-checkout",
"allow-commit",
"allow-delete-branch",
"allow-initialize",
"allow-log",
"allow-merge-branch",
"allow-pull",
"allow-push",
"allow-status",
"allow-unstage",
]

View File

@@ -0,0 +1,425 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the add command without any pre-configured scope.",
"type": "string",
"const": "allow-add"
},
{
"description": "Denies the add command without any pre-configured scope.",
"type": "string",
"const": "deny-add"
},
{
"description": "Enables the branch command without any pre-configured scope.",
"type": "string",
"const": "allow-branch"
},
{
"description": "Denies the branch command without any pre-configured scope.",
"type": "string",
"const": "deny-branch"
},
{
"description": "Enables the checkout command without any pre-configured scope.",
"type": "string",
"const": "allow-checkout"
},
{
"description": "Denies the checkout command without any pre-configured scope.",
"type": "string",
"const": "deny-checkout"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
"const": "allow-commit"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
"const": "deny-commit"
},
{
"description": "Enables the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "allow-delete-branch"
},
{
"description": "Denies the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "deny-delete-branch"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
"const": "allow-initialize"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",
"const": "deny-initialize"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "allow-log"
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "deny-log"
},
{
"description": "Enables the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "allow-merge-branch"
},
{
"description": "Denies the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "deny-merge-branch"
},
{
"description": "Enables the pull command without any pre-configured scope.",
"type": "string",
"const": "allow-pull"
},
{
"description": "Denies the pull command without any pre-configured scope.",
"type": "string",
"const": "deny-pull"
},
{
"description": "Enables the push command without any pre-configured scope.",
"type": "string",
"const": "allow-push"
},
{
"description": "Denies the push command without any pre-configured scope.",
"type": "string",
"const": "deny-push"
},
{
"description": "Enables the status command without any pre-configured scope.",
"type": "string",
"const": "allow-status"
},
{
"description": "Denies the status command without any pre-configured scope.",
"type": "string",
"const": "deny-status"
},
{
"description": "Enables the unstage command without any pre-configured scope.",
"type": "string",
"const": "allow-unstage"
},
{
"description": "Denies the unstage command without any pre-configured scope.",
"type": "string",
"const": "deny-unstage"
},
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "default"
}
]
}
}
}

View File

@@ -0,0 +1,90 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{
bytes_to_string, get_branch_by_name, get_current_branch, get_default_remote_for_push_in_repo,
};
use git2::build::CheckoutBuilder;
use git2::{BranchType, Repository};
use log::info;
use std::path::Path;
pub(crate) fn branch_set_upstream_after_push(repo: &Repository, branch_name: &str) -> Result<()> {
let mut branch = repo.find_branch(branch_name, BranchType::Local)?;
if branch.upstream().is_err() {
let remote = get_default_remote_for_push_in_repo(repo)?;
let upstream_name = format!("{remote}/{branch_name}");
branch.set_upstream(Some(upstream_name.as_str()))?;
}
Ok(())
}
pub(crate) fn git_checkout_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
let repo = open_repo(dir)?;
let branch = get_branch_by_name(&repo, branch)?;
let branch_ref = branch.into_reference();
let branch_tree = branch_ref.peel_to_tree()?;
let mut options = CheckoutBuilder::default();
if force {
options.force();
}
repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?;
repo.set_head(branch_ref.name().unwrap())?;
Ok(())
}
pub(crate) fn git_create_branch(dir: &Path, name: &str) -> Result<()> {
let repo = open_repo(dir)?;
let head = match repo.head() {
Ok(h) => h,
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
let msg = "Cannot create branch when there are no commits";
return Err(GenericError(msg.into()));
}
Err(e) => return Err(e.into()),
};
let head = head.peel_to_commit()?;
repo.branch(name, &head, false)?;
Ok(())
}
pub(crate) fn git_delete_branch(dir: &Path, name: &str) -> Result<()> {
let repo = open_repo(dir)?;
let mut branch = get_branch_by_name(&repo, name)?;
if branch.is_head() {
info!("Deleting head branch");
let branches = repo.branches(Some(BranchType::Local))?;
let other_branch = branches.into_iter().filter_map(|b| b.ok()).find(|b| !b.0.is_head());
let other_branch = match other_branch {
None => return Err(GenericError("Cannot delete only branch".into())),
Some(b) => bytes_to_string(b.0.name_bytes()?)?,
};
git_checkout_branch(dir, &other_branch, true)?;
}
branch.delete()?;
Ok(())
}
pub(crate) fn git_merge_branch(dir: &Path, name: &str, _force: bool) -> Result<()> {
let repo = open_repo(dir)?;
let local_branch = get_current_branch(&repo)?.unwrap();
let commit_to_merge = get_branch_by_name(&repo, name)?.into_reference();
let commit_to_merge = repo.reference_to_annotated_commit(&commit_to_merge)?;
do_merge(&repo, &local_branch, &commit_to_merge)?;
Ok(())
}

View File

@@ -0,0 +1,76 @@
use git2::{Cred, RemoteCallbacks};
use log::{debug, info};
use crate::util::find_ssh_key;
pub(crate) fn default_callbacks<'s>() -> RemoteCallbacks<'s> {
let mut callbacks = RemoteCallbacks::new();
let mut fail_next_call = false;
let mut tried_agent = false;
callbacks.credentials(move |url, username_from_url, allowed_types| {
if fail_next_call {
info!("Failed to get credentials for push");
return Err(git2::Error::from_str("Bad credentials."));
}
debug!("getting credentials {url} {username_from_url:?} {allowed_types:?}");
match (allowed_types.is_ssh_key(), username_from_url) {
(true, Some(username)) => {
if !tried_agent {
tried_agent = true;
return Cred::ssh_key_from_agent(username);
}
fail_next_call = true; // This is our last try
// If the agent failed, try using the default SSH key
if let Some(key) = find_ssh_key() {
Cred::ssh_key(username, None, key.as_path(), None)
} else {
Err(git2::Error::from_str(
"Bad credentials. Ensure your key was added using ssh-add",
))
}
}
(true, None) => Err(git2::Error::from_str("Couldn't get username from url")),
_ => {
todo!("Implement basic auth credential");
}
}
});
callbacks.push_transfer_progress(|current, total, bytes| {
debug!("progress: {}/{} ({} B)", current, total, bytes,);
});
callbacks.transfer_progress(|p| {
debug!("transfer: {}/{}", p.received_objects(), p.total_objects());
true
});
callbacks.pack_progress(|stage, current, total| {
debug!("packing: {:?} - {}/{}", stage, current, total);
});
callbacks.push_update_reference(|reference, msg| {
debug!("push_update_reference: '{}' {:?}", reference, msg);
Ok(())
});
callbacks.update_tips(|name, a, b| {
debug!("update tips: '{}' {} -> {}", name, a, b);
if a != b {
// let mut push_result = push_result.lock().unwrap();
// *push_result = PushResult::Success
}
true
});
callbacks.sideband_progress(|data| {
debug!("sideband transfer: '{}'", String::from_utf8_lossy(data).trim());
true
});
callbacks
}

View File

@@ -0,0 +1,76 @@
use crate::branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
use crate::error::Result;
use crate::git::{
git_add, git_commit, git_init, git_log, git_status, git_unstage, GitCommit, GitStatusSummary,
};
use crate::pull::{git_pull, PullResult};
use crate::push::{git_push, PushResult};
use std::path::{Path, PathBuf};
use tauri::command;
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
#[command]
pub async fn checkout(dir: &Path, branch: &str, force: bool) -> Result<()> {
git_checkout_branch(dir, branch, force)
}
#[command]
pub async fn branch(dir: &Path, branch: &str) -> Result<()> {
git_create_branch(dir, branch)
}
#[command]
pub async fn delete_branch(dir: &Path, branch: &str) -> Result<()> {
git_delete_branch(dir, branch)
}
#[command]
pub async fn merge_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
git_merge_branch(dir, branch, force)
}
#[command]
pub async fn status(dir: &Path) -> Result<GitStatusSummary> {
git_status(dir)
}
#[command]
pub async fn log(dir: &Path) -> Result<Vec<GitCommit>> {
git_log(dir)
}
#[command]
pub async fn initialize(dir: &Path) -> Result<()> {
git_init(dir)
}
#[command]
pub async fn commit(dir: &Path, message: &str) -> Result<()> {
git_commit(dir, message)
}
#[command]
pub async fn push(dir: &Path) -> Result<PushResult> {
git_push(dir)
}
#[command]
pub async fn pull(dir: &Path) -> Result<PullResult> {
git_pull(dir)
}
#[command]
pub async fn add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
for path in rela_paths {
git_add(dir, &path)?;
}
Ok(())
}
#[command]
pub async fn unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
for path in rela_paths {
git_unstage(dir, &path)?;
}
Ok(())
}

View File

@@ -0,0 +1,55 @@
use serde::{Serialize, Serializer};
use std::io;
use std::path::PathBuf;
use std::string::FromUtf8Error;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Git repo not found {0}")]
GitRepoNotFound(PathBuf),
#[error("Git error: {0}")]
GitUnknown(#[from] git2::Error),
#[error("Yaml error: {0}")]
YamlParseError(#[from] serde_yaml::Error),
#[error("Yaml error: {0}")]
ModelError(#[from] yaak_models::error::Error),
#[error("Sync error: {0}")]
SyncError(#[from] yaak_sync::error::Error),
#[error("I/o error: {0}")]
IoError(#[from] io::Error),
#[error("Yaml error: {0}")]
JsonParseError(#[from] serde_json::Error),
#[error("Yaml error: {0}")]
Utf8ConversionError(#[from] FromUtf8Error),
#[error("Git error: {0}")]
GenericError(String),
#[error("No default remote found")]
NoDefaultRemoteFound,
#[error("Merge failed due to conflicts")]
MergeConflicts,
#[error("No active branch")]
NoActiveBranch,
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,670 @@
use crate::error::Result;
use crate::repository::open_repo;
use crate::util::list_branch_names;
use chrono::{DateTime, Utc};
use git2::IndexAddOption;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
use yaak_sync::models::SyncModel;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusSummary {
pub path: String,
pub head_ref: Option<String>,
pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>,
pub origins: Vec<String>,
pub branches: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusEntry {
pub rela_path: String,
pub status: GitStatus,
pub staged: bool,
pub prev: Option<SyncModel>,
pub next: Option<SyncModel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub enum GitStatus {
Added,
Conflict,
Current,
Modified,
Removed,
Renamed,
TypeChange,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitCommit {
author: GitAuthor,
when: DateTime<Utc>,
message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitAuthor {
name: Option<String>,
email: Option<String>,
}
pub fn git_init(dir: &Path) -> Result<()> {
git2::Repository::init(dir)?;
let repo = open_repo(dir)?;
// Default to main instead of master, to align with
// the official Git and GitHub behavior
repo.set_head("refs/heads/main")?;
info!("Initialized {dir:?}");
Ok(())
}
pub fn git_add(dir: &Path, rela_path: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let mut index = repo.index()?;
info!("Staging file {rela_path:?} to {dir:?}");
index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?;
index.write()?;
Ok(())
}
pub fn git_unstage(dir: &Path, rela_path: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let head = match repo.head() {
Ok(h) => h,
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
info!("Unstaging file in empty branch {rela_path:?} to {dir:?}");
// Repo has no commits, so "unstage" means remove from index
let mut index = repo.index()?;
index.remove_path(rela_path)?;
index.write()?;
return Ok(());
}
Err(e) => return Err(e.into()),
};
// If repo has commits, update the index entry back to HEAD
info!("Unstaging file {rela_path:?} to {dir:?}");
let commit = head.peel_to_commit()?;
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
Ok(())
}
pub fn git_commit(dir: &Path, message: &str) -> Result<()> {
let repo = open_repo(dir)?;
// Clear the in-memory index, add the paths, and write the tree for committing
let tree_oid = repo.index()?.write_tree()?;
let tree = repo.find_tree(tree_oid)?;
// Make the signature
let config = git2::Config::open_default()?.snapshot()?;
let name = config.get_str("user.name").unwrap_or("Change Me");
let email = config.get_str("user.email").unwrap_or("change_me@example.com");
let sig = git2::Signature::now(name, email)?;
// Get the current HEAD commit (if it exists)
let parent_commit = match repo.head() {
Ok(head) => Some(head.peel_to_commit()?),
Err(_) => None, // No parent if no HEAD exists (initial commit)
};
let parents = parent_commit.as_ref().map(|p| vec![p]).unwrap_or_default();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, parents.as_slice())?;
info!("Committed to {dir:?}");
Ok(())
}
pub fn git_log(dir: &Path) -> Result<Vec<GitCommit>> {
let repo = open_repo(dir)?;
// Return empty if empty repo or no head (new repo)
if repo.is_empty()? || repo.head().is_err() {
return Ok(vec![]);
}
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
// Run git log
macro_rules! filter_try {
($e:expr) => {
match $e {
Ok(t) => t,
Err(_) => return None,
}
};
}
let log: Vec<GitCommit> = revwalk
.filter_map(|oid| {
let oid = filter_try!(oid);
let commit = filter_try!(repo.find_commit(oid));
let author = commit.author();
Some(GitCommit {
author: GitAuthor {
name: author.name().map(|s| s.to_string()),
email: author.email().map(|s| s.to_string()),
},
when: convert_git_time_to_date(author.when()),
message: commit.message().map(|m| m.to_string()),
})
})
.collect();
Ok(log)
}
pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
let repo = open_repo(dir)?;
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
Ok(head) => {
let tree = head.peel_to_tree().ok();
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
let head_ref = head.name().map(|s| s.to_string());
(tree, head_ref, head_ref_shorthand)
}
Err(_) => {
// For "unborn" repos, reading from HEAD is the only way to get the branch name
// See https://github.com/starship/starship/pull/1336
let head_path = repo.path().join("HEAD");
let head_ref = fs::read_to_string(&head_path)
.ok()
.unwrap_or_default()
.lines()
.next()
.map(|s| s.trim_start_matches("ref:").trim().to_string());
let head_ref_shorthand =
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
(None, head_ref, head_ref_shorthand)
}
};
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
.include_untracked(true) // Include untracked
.recurse_untracked_dirs(true) // Show all untracked
.include_unmodified(true); // Include unchanged
// TODO: Support renames
let mut entries: Vec<GitStatusEntry> = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
let rela_path = entry.path().unwrap().to_string();
let status = entry.status();
let index_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Added,
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown index status {s:?}");
continue;
}
};
let worktree_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::WT_NEW) => GitStatus::Added,
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown worktree status {s:?}");
continue;
}
};
let status = if index_status == GitStatus::Current {
worktree_status.clone()
} else {
index_status.clone()
};
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
{
// No change, so can't be added
false
} else if index_status != GitStatus::Current {
true
} else {
false
};
// Get previous content from Git, if it's in there
let prev = match head_tree.clone() {
None => None,
Some(t) => match t.get_path(&Path::new(&rela_path)) {
Ok(entry) => {
let obj = entry.to_object(&repo).unwrap();
let content = obj.as_blob().unwrap().content();
let name = Path::new(entry.name().unwrap_or_default());
SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)
}
Err(_) => None,
},
};
let next = {
let full_path = repo.workdir().unwrap().join(rela_path.clone());
SyncModel::from_file(full_path.as_path())?.map(|m| m.0)
};
entries.push(GitStatusEntry {
status,
staged,
rela_path,
prev: prev.clone(),
next: next.clone(),
})
}
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
let branches = list_branch_names(&repo)?;
Ok(GitStatusSummary {
entries,
origins,
path: dir.to_string_lossy().to_string(),
head_ref,
head_ref_shorthand,
branches,
})
}
#[cfg(test)]
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).unwrap()
}
#[cfg(not(test))]
fn convert_git_time_to_date(git_time: git2::Time) -> DateTime<Utc> {
let timestamp = git_time.seconds();
DateTime::from_timestamp(timestamp, 0).unwrap()
}
// // Write a test
// #[cfg(test)]
// mod test {
// use crate::error::Error::GitRepoNotFound;
// use crate::error::Result;
// use crate::git::{
// git_add, git_commit, git_init, git_log, git_status, git_unstage, open_repo, GitStatus,
// GitStatusEntry,
// };
// use std::fs::{create_dir_all, remove_file, File};
// use std::io::Write;
// use std::path::{Path, PathBuf};
// use tempdir::TempDir;
//
// fn new_dir() -> PathBuf {
// let p = TempDir::new("yaak-git").unwrap().into_path();
// p
// }
//
// fn new_file(path: &Path, content: &str) {
// let parent = path.parent().unwrap();
// create_dir_all(parent).unwrap();
// File::create(path).unwrap().write_all(content.as_bytes()).unwrap();
// }
//
// #[tokio::test]
// async fn test_status_no_repo() {
// let dir = &new_dir();
// let result = git_status(dir).await;
// assert!(matches!(result, Err(GitRepoNotFound(_))));
// }
//
// #[test]
// fn test_open_repo() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
// open_repo(dir.as_path())?;
// Ok(())
// }
//
// #[test]
// fn test_open_repo_from_subdir() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let sub_dir = dir.join("a").join("b");
// create_dir_all(sub_dir.as_path())?; // Create sub dir
//
// open_repo(sub_dir.as_path())?;
// Ok(())
// }
//
// #[tokio::test]
// async fn test_status() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// assert_eq!(git_status(dir).await?.entries, Vec::new());
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
// new_file(&dir.join("dir/baz.txt"), "baz");
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "dir/baz.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("baz".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_add() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo".to_string()),
// },
// ],
// );
//
// new_file(&dir.join("foo.txt"), "foo foo");
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo foo".to_string()),
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_unstage() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo".to_string()),
// },
// ]
// );
//
// git_unstage(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// }
// ]
// );
//
// Ok(())
// }
//
// #[tokio::test]
// fn test_commit() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// },
// ]
// );
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "This is my message")?;
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Current,
// staged: false,
// prev: Some("foo".to_string()),
// next: Some("foo".to_string()),
// },
// ]
// );
//
// new_file(&dir.join("foo.txt"), "foo foo");
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Modified,
// staged: true,
// prev: Some("foo".to_string()),
// next: Some("foo foo".to_string()),
// },
// ]
// );
// Ok(())
// }
//
// #[tokio::test]
// async fn test_add_removed_file() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let foo_path = &dir.join("foo.txt");
// let bar_path = &dir.join("bar.txt");
//
// new_file(foo_path, "foo");
// new_file(bar_path, "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "Initial commit")?;
//
// remove_file(foo_path)?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Removed,
// staged: false,
// prev: Some("foo".to_string()),
// next: None,
// },
// ],
// );
//
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Removed,
// staged: true,
// prev: Some("foo".to_string()),
// next: None,
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_log_empty() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let log = git_log(dir)?;
// assert_eq!(log.len(), 0);
// Ok(())
// }
//
// #[test]
// fn test_log() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "This is my message")?;
//
// let log = git_log(dir)?;
// assert_eq!(log.len(), 1);
// assert_eq!(log.get(0).unwrap().message, Some("This is my message".to_string()));
// Ok(())
// }
// }

View File

@@ -0,0 +1,36 @@
use crate::commands::{add, branch, checkout, commit, delete_branch, initialize, log, merge_branch, pull, push, status, unstage};
use tauri::{
generate_handler,
plugin::{Builder, TauriPlugin},
Runtime,
};
mod branch;
mod callbacks;
mod commands;
mod error;
mod git;
mod merge;
mod pull;
mod push;
mod repository;
mod util;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-git")
.invoke_handler(generate_handler![
add,
branch,
checkout,
commit,
delete_branch,
initialize,
log,
merge_branch,
pull,
push,
status,
unstage
])
.build()
}

View File

@@ -0,0 +1,135 @@
use crate::error::Error::MergeConflicts;
use crate::util::bytes_to_string;
use git2::{AnnotatedCommit, Branch, IndexEntry, Reference, Repository};
use log::{debug, info};
pub(crate) fn do_merge(
repo: &Repository,
local_branch: &Branch,
commit_to_merge: &AnnotatedCommit,
) -> crate::error::Result<()> {
debug!("Merging remote branches");
let analysis = repo.merge_analysis(&[&commit_to_merge])?;
if analysis.0.is_fast_forward() {
let refname = bytes_to_string(local_branch.get().name_bytes())?;
match repo.find_reference(&refname) {
Ok(mut r) => {
merge_fast_forward(repo, &mut r, &commit_to_merge)?;
}
Err(_) => {
// The branch doesn't exist, so set the reference to the commit directly. Usually
// this is because you are pulling into an empty repository.
repo.reference(
&refname,
commit_to_merge.id(),
true,
&format!("Setting {} to {}", refname, commit_to_merge.id()),
)?;
repo.set_head(&refname)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
.allow_conflicts(true)
.conflict_style_merge(true)
.force(),
))?;
}
};
} else if analysis.0.is_normal() {
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
merge_normal(repo, &head_commit, commit_to_merge)?;
} else {
debug!("Skipping merge. Nothing to do")
}
Ok(())
}
pub(crate) fn merge_fast_forward(
repo: &Repository,
local_reference: &mut Reference,
remote_commit: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing fast forward");
let name = match local_reference.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(local_reference.name_bytes()).to_string(),
};
let msg = format!("Fast-Forward: Setting {} to id: {}", name, remote_commit.id());
local_reference.set_target(remote_commit.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
// For some reason, the force is required to make the working directory actually get
// updated I suspect we should be adding some logic to handle dirty working directory
// states, but this is just an example so maybe not.
.force(),
))?;
Ok(())
}
pub(crate) fn merge_normal(
repo: &Repository,
local: &AnnotatedCommit,
remote: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing normal merge");
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let ancestor = repo.find_commit(repo.merge_base(local.id(), remote.id())?)?.tree()?;
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
if idx.has_conflicts() {
let conflicts = idx.conflicts()?;
for conflict in conflicts {
if let Ok(conflict) = conflict {
print_conflict(&conflict);
}
}
return Err(MergeConflicts);
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
// now create the merge commit
let msg = format!("Merge: {} into {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
// Do our merge commit and set current branch head to that commit.
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
&sig,
&msg,
&result_tree,
&[&local_commit, &remote_commit],
)?;
// Set working tree to match head.
repo.checkout_head(None)?;
Ok(())
}
fn print_conflict(conflict: &git2::IndexConflict) {
let ancestor = conflict.ancestor.as_ref().map(path_from_index_entry);
let ours = conflict.our.as_ref().map(path_from_index_entry);
let theirs = conflict.their.as_ref().map(path_from_index_entry);
println!("Conflict detected:");
if let Some(path) = ancestor {
println!(" Common ancestor: {:?}", path);
}
if let Some(path) = ours {
println!(" Ours: {:?}", path);
}
if let Some(path) = theirs {
println!(" Theirs: {:?}", path);
}
}
fn path_from_index_entry(entry: &IndexEntry) -> String {
String::from_utf8_lossy(entry.path.as_slice()).into_owned()
}

View File

@@ -0,0 +1,54 @@
use crate::callbacks::default_callbacks;
use crate::error::Error::NoActiveBranch;
use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{bytes_to_string, get_current_branch};
use git2::{FetchOptions, ProxyOptions};
use log::debug;
use serde::{Deserialize, Serialize};
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) struct PullResult {
received_bytes: usize,
received_objects: usize,
}
pub(crate) fn git_pull(dir: &Path) -> Result<PullResult> {
let repo = open_repo(dir)?;
let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;
let branch_ref = branch.get();
let branch_ref = bytes_to_string(branch_ref.name_bytes())?;
let remote_name = repo.branch_upstream_remote(&branch_ref)?;
let remote_name = bytes_to_string(&remote_name)?;
debug!("Pulling from {remote_name}");
let mut remote = repo.find_remote(&remote_name)?;
let mut options = FetchOptions::new();
let callbacks = default_callbacks();
options.remote_callbacks(callbacks);
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
remote.fetch(&[&branch_ref], Some(&mut options), None)?;
let stats = remote.stats();
let fetch_head = repo.find_reference("FETCH_HEAD")?;
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
do_merge(&repo, &branch, &fetch_commit)?;
Ok(PullResult {
received_bytes: stats.received_bytes(),
received_objects: stats.received_objects(),
})
}

View File

@@ -0,0 +1,74 @@
use crate::branch::branch_set_upstream_after_push;
use crate::callbacks::default_callbacks;
use crate::error::Result;
use crate::repository::open_repo;
use git2::{ProxyOptions, PushOptions};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Mutex;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) enum PushType {
Branch,
Tag,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) enum PushResult {
Success,
NothingToPush,
}
pub(crate) fn git_push(dir: &Path) -> Result<PushResult> {
let repo = open_repo(dir)?;
let head = repo.head()?;
let branch = head.shorthand().unwrap();
let mut remote = repo.find_remote("origin")?;
let mut options = PushOptions::new();
options.packbuilder_parallelism(0);
let push_result = Mutex::new(PushResult::NothingToPush);
let mut callbacks = default_callbacks();
callbacks.push_transfer_progress(|_current, _total, _bytes| {
let mut push_result = push_result.lock().unwrap();
*push_result = PushResult::Success;
});
options.remote_callbacks(default_callbacks());
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
// Push the current branch
let force = false;
let delete = false;
let branch_modifier = match (force, delete) {
(true, true) => "+:",
(false, true) => ":",
(true, false) => "+",
(false, false) => "",
};
let ref_type = PushType::Branch;
let ref_type = match ref_type {
PushType::Branch => "heads",
PushType::Tag => "tags",
};
let refspec = format!("{branch_modifier}refs/{ref_type}/{branch}");
remote.push(&[refspec], Some(&mut options))?;
branch_set_upstream_after_push(&repo, branch)?;
let push_result = push_result.lock().unwrap();
Ok(push_result.clone())
}

View File

@@ -0,0 +1,11 @@
use std::path::Path;
use crate::error::Error::{GitRepoNotFound, GitUnknown};
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
match git2::Repository::discover(dir) {
Ok(r) => Ok(r),
Err(e) if e.code() == git2::ErrorCode::NotFound => Err(GitRepoNotFound(dir.to_path_buf())),
Err(e) => Err(GitUnknown(e)),
}
}

View File

@@ -0,0 +1,107 @@
use crate::error::Error::{GenericError, NoDefaultRemoteFound};
use crate::error::Result;
use git2::{Branch, BranchType, Repository};
use std::env;
use std::path::{Path, PathBuf};
const DEFAULT_REMOTE_NAME: &str = "origin";
pub(crate) fn find_ssh_key() -> Option<PathBuf> {
let home_dir = env::var("HOME").ok()?;
let key_paths = [
format!("{}/.ssh/id_ed25519", home_dir),
format!("{}/.ssh/id_rsa", home_dir),
format!("{}/.ssh/id_ecdsa", home_dir),
format!("{}/.ssh/id_dsa", home_dir),
];
for key_path in key_paths.iter() {
let path = Path::new(key_path);
if path.exists() {
return Some(path.to_path_buf());
}
}
None
}
pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch>> {
for b in repo.branches(None)? {
let branch = b?.0;
if branch.is_head() {
return Ok(Some(branch));
}
}
Ok(None)
}
pub(crate) fn list_branch_names(repo: &Repository) -> Result<Vec<String>> {
let mut branches = Vec::new();
for branch in repo.branches(Some(BranchType::Local))? {
let branch = branch?.0;
let name = branch.name_bytes()?;
let name = bytes_to_string(name)?;
branches.push(name);
}
Ok(branches)
}
pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result<Branch<'s>> {
Ok(repo.find_branch(name, BranchType::Local)?)
}
pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
Ok(String::from_utf8(bytes.to_vec())?)
}
pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result<String> {
let config = repo.config()?;
let branch = get_current_branch(repo)?;
if let Some(branch) = branch {
let remote_name = bytes_to_string(branch.name_bytes()?)?;
let entry_name = format!("branch.{}.pushRemote", &remote_name);
if let Ok(entry) = config.get_entry(&entry_name) {
return bytes_to_string(entry.value_bytes());
}
if let Ok(entry) = config.get_entry("remote.pushDefault") {
return bytes_to_string(entry.value_bytes());
}
let entry_name = format!("branch.{}.remote", &remote_name);
if let Ok(entry) = config.get_entry(&entry_name) {
return bytes_to_string(entry.value_bytes());
}
}
get_default_remote_in_repo(repo)
}
pub(crate) fn get_default_remote_in_repo(repo: &Repository) -> Result<String> {
let remotes = repo.remotes()?;
// if `origin` exists return that
let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));
if found_origin {
return Ok(DEFAULT_REMOTE_NAME.into());
}
// if only one remote exists pick that
if remotes.len() == 1 {
let first_remote = remotes
.iter()
.next()
.flatten()
.map(String::from)
.ok_or_else(|| GenericError("no remote found".into()))?;
return Ok(first_remote);
}
// inconclusive
Err(NoDefaultRemoteFound)
}

View File

@@ -147,7 +147,7 @@ impl<'s> TryFrom<&Row<'s>> for Settings {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct Workspace {
@@ -325,7 +325,7 @@ impl<'s> TryFrom<&Row<'s>> for CookieJar {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct Environment {
@@ -374,7 +374,7 @@ impl<'s> TryFrom<&Row<'s>> for Environment {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct EnvironmentVariable {
@@ -387,7 +387,7 @@ pub struct EnvironmentVariable {
pub id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct Folder {
@@ -438,7 +438,7 @@ impl<'s> TryFrom<&Row<'s>> for Folder {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequestHeader {
@@ -451,7 +451,7 @@ pub struct HttpRequestHeader {
pub id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpUrlParameter {
@@ -464,7 +464,7 @@ pub struct HttpUrlParameter {
pub id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequest {
@@ -637,7 +637,7 @@ impl Default for WebsocketMessageType {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct WebsocketRequest {
@@ -895,7 +895,7 @@ impl HttpResponse {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct GrpcMetadataEntry {
@@ -908,7 +908,7 @@ pub struct GrpcMetadataEntry {
pub id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct GrpcRequest {

View File

@@ -2569,20 +2569,22 @@ pub async fn batch_upsert<R: Runtime>(
let mut imported_resources = BatchUpsertResult::default();
if workspaces.len() > 0 {
info!("Batch inserting {} workspaces", workspaces.len());
for v in workspaces {
let x = upsert_workspace(&window, v, update_source).await?;
imported_resources.workspaces.push(x.clone());
}
info!("Imported {} workspaces", imported_resources.workspaces.len());
}
if environments.len() > 0 {
while imported_resources.environments.len() < environments.len() {
for v in environments.clone() {
if let Some(fid) = v.environment_id.clone() {
if let Some(id) = v.environment_id.clone() {
let has_parent_to_import = environments.iter().find(|m| m.id == id).is_some();
let imported_parent =
imported_resources.environments.iter().find(|f| f.id == fid);
if imported_parent.is_none() {
imported_resources.environments.iter().find(|m| m.id == id);
// If there's also a parent to upsert, wait for that one
if imported_parent.is_none() && has_parent_to_import {
continue;
}
}
@@ -2599,9 +2601,11 @@ pub async fn batch_upsert<R: Runtime>(
if folders.len() > 0 {
while imported_resources.folders.len() < folders.len() {
for v in folders.clone() {
if let Some(fid) = v.folder_id.clone() {
let imported_parent = imported_resources.folders.iter().find(|f| f.id == fid);
if imported_parent.is_none() {
if let Some(id) = v.folder_id.clone() {
let has_parent_to_import = folders.iter().find(|m| m.id == id).is_some();
let imported_parent = imported_resources.folders.iter().find(|m| m.id == id);
// If there's also a parent to upsert, wait for that one
if imported_parent.is_none() && has_parent_to_import {
continue;
}
}

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GitCommit = { author: string, when: string, message: string | null, };
export type GitStatus = "added" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: string | null, next: string | null, };

View File

@@ -3,6 +3,8 @@ import { emit } from '@tauri-apps/api/event';
import { SyncOp } from './bindings/gen_sync';
import { WatchEvent, WatchResult } from './bindings/gen_watch';
export * from './bindings/gen_models';
export async function calculateSync(workspaceId: string, syncDir: string) {
return invoke<SyncOp[]>('plugin:yaak-sync|calculate', {
workspaceId,
@@ -27,20 +29,53 @@ export function watchWorkspaceFiles(
syncDir: string,
callback: (e: WatchEvent) => void,
) {
console.log('Watching workspace files', workspaceId, syncDir);
const channel = new Channel<WatchEvent>();
channel.onmessage = callback;
const promise = invoke<WatchResult>('plugin:yaak-sync|watch', {
const unlistenPromise = invoke<WatchResult>('plugin:yaak-sync|watch', {
workspaceId,
syncDir,
channel,
});
return () => {
promise
.then(({ unlistenEvent }) => {
console.log('Cancelling workspace watch', workspaceId, unlistenEvent);
return emit(unlistenEvent);
unlistenPromise.then(({ unlistenEvent }) => {
addWatchKey(unlistenEvent);
});
return () =>
unlistenPromise
.then(async ({ unlistenEvent }) => {
console.log('Unwatching workspace files', workspaceId, syncDir);
unlistenToWatcher(unlistenEvent);
})
.catch(console.error);
};
}
function unlistenToWatcher(unlistenEvent: string) {
emit(unlistenEvent).then(() => {
removeWatchKey(unlistenEvent);
});
}
function getWatchKeys() {
return sessionStorage.getItem('workspace-file-watchers')?.split(',').filter(Boolean) ?? [];
}
function setWatchKeys(keys: string[]) {
sessionStorage.setItem('workspace-file-watchers', keys.join(','));
}
function addWatchKey(key: string) {
const keys = getWatchKeys();
setWatchKeys([...keys, key]);
}
function removeWatchKey(key: string) {
const keys = getWatchKeys();
setWatchKeys(keys.filter((k) => k !== key));
}
// On page load, unlisten to all zombie watchers
const keys = getWatchKeys();
console.log('Unsubscribing to zombie file watchers', keys);
keys.forEach(unlistenToWatcher);

View File

@@ -6,8 +6,8 @@ use tauri::{
};
mod commands;
mod error;
mod models;
pub mod error;
pub mod models;
mod sync;
mod watch;

View File

@@ -3,14 +3,14 @@ use crate::error::Result;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use std::fs;
use std::path::Path;
use tokio::fs;
use ts_rs::TS;
use yaak_models::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_models.ts")]
pub enum SyncModel {
@@ -23,12 +23,10 @@ pub enum SyncModel {
}
impl SyncModel {
pub async fn from_file(file_path: &Path) -> Result<Option<(SyncModel, Vec<u8>, String)>> {
let content = match fs::read(file_path).await {
Ok(c) => c,
Err(_) => return Ok(None),
};
pub fn from_bytes(
content: Vec<u8>,
file_path: &Path,
) -> Result<Option<(SyncModel, Vec<u8>, String)>> {
let mut hasher = Sha1::new();
hasher.update(&content);
let checksum = hex::encode(hasher.finalize());
@@ -39,10 +37,20 @@ impl SyncModel {
} else if ext == "json" {
Ok(Some((serde_json::from_reader(content.as_slice())?, content, checksum)))
} else {
Err(InvalidSyncFile(file_path.to_str().unwrap().to_string()))
let p = file_path.to_str().unwrap().to_string();
Err(InvalidSyncFile(format!("Unknown file extension {p}")))
}
}
pub fn from_file(file_path: &Path) -> Result<Option<(SyncModel, Vec<u8>, String)>> {
let content = match fs::read(file_path) {
Ok(c) => c,
Err(_) => return Ok(None),
};
Self::from_bytes(content, file_path)
}
pub fn to_file_contents(&self, rel_path: &Path) -> Result<(Vec<u8>, String)> {
let ext = rel_path.extension().unwrap_or_default();
let content = if ext == "yaml" || ext == "yml" {

View File

@@ -1,4 +1,3 @@
use crate::error::Error::InvalidSyncFile;
use crate::error::Result;
use crate::models::SyncModel;
use chrono::Utc;
@@ -164,13 +163,12 @@ pub(crate) async fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
};
let path = dir_entry.path();
let (model, _, checksum) = match SyncModel::from_file(&path).await {
let (model, _, checksum) = match SyncModel::from_file(&path) {
Ok(Some(m)) => m,
Ok(None) => continue,
Err(InvalidSyncFile(_)) => continue,
Err(e) => {
warn!("Failed to read sync file {e}");
continue;
return Err(e);
}
};
@@ -315,7 +313,7 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
}
debug!(
"Sync ops {}",
"Applying sync ops {}",
sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ")
);
let mut sync_state_ops = Vec::new();

View File

@@ -45,9 +45,19 @@ pub(crate) async fn watch_directory(
Some(event_res) = async_rx.recv() => {
match event_res {
Ok(event) => {
// Filter out any ignored directories and see if we still get a result
let paths = event.paths.into_iter()
.map(|p| p.strip_prefix(&dir).unwrap().to_path_buf())
.filter(|p| !p.starts_with(".git") && !p.starts_with("node_modules"))
.collect::<Vec<PathBuf>>();
if paths.is_empty() {
continue;
}
channel
.send(WatchEvent {
paths: event.paths,
paths,
kind: format!("{:?}", event.kind),
})
.expect("Failed to send watch event");

View File

@@ -1,10 +1,10 @@
mod cmd;
mod commands;
mod connect;
mod error;
mod manager;
mod render;
use crate::cmd::{
use crate::commands::{
connect, close, delete_connection, delete_connections, delete_request, duplicate_request,
list_connections, list_events, list_requests, send, upsert_request,
};