mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-09-11 12:25:42 +03:00
Merge branch 'master' into ndom91/create-gitbutler-ui-package
This commit is contained in:
commit
836b86a753
47
Cargo.lock
generated
47
Cargo.lock
generated
@ -1581,18 +1581,6 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "faster-hex"
|
||||
version = "0.9.0"
|
||||
@ -2141,7 +2129,6 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest 0.12.4",
|
||||
"resolve-path",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@ -3128,15 +3115,6 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hdrhistogram"
|
||||
version = "7.5.4"
|
||||
@ -3810,17 +3788,6 @@ dependencies = [
|
||||
"redox_syscall 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.0"
|
||||
@ -5441,20 +5408,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2"
|
||||
dependencies = [
|
||||
"bitflags 2.5.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.35.0"
|
||||
|
@ -16,7 +16,6 @@ git2 = { version = "0.18.3", features = ["vendored-openssl", "vendored-libgit2"]
|
||||
uuid = { version = "1.8.0", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "1.0.61"
|
||||
rusqlite = { version = "0.29.0", features = [ "bundled", "blob" ] }
|
||||
tokio = { version = "1.38.0", default-features = false }
|
||||
|
||||
gitbutler-git = { path = "crates/gitbutler-git" }
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { SHORT_DEFAULT_COMMIT_TEMPLATE, SHORT_DEFAULT_BRANCH_TEMPLATE } from '$lib/ai/prompts';
|
||||
import { type AIClient, type AnthropicModelName, type Prompt } from '$lib/ai/types';
|
||||
import { buildFailureFromAny, ok, type Result } from '$lib/result';
|
||||
import { fetch, Body } from '@tauri-apps/api/http';
|
||||
import type { AIClient, AnthropicModelName, Prompt } from '$lib/ai/types';
|
||||
|
||||
type AnthropicAPIResponse = { content: { text: string }[] };
|
||||
type AnthropicAPIResponse = {
|
||||
content: { text: string }[];
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
export class AnthropicAIClient implements AIClient {
|
||||
defaultCommitTemplate = SHORT_DEFAULT_COMMIT_TEMPLATE;
|
||||
@ -13,7 +17,7 @@ export class AnthropicAIClient implements AIClient {
|
||||
private modelName: AnthropicModelName
|
||||
) {}
|
||||
|
||||
async evaluate(prompt: Prompt) {
|
||||
async evaluate(prompt: Prompt): Promise<Result<string, Error>> {
|
||||
const body = Body.json({
|
||||
messages: prompt,
|
||||
max_tokens: 1024,
|
||||
@ -30,6 +34,12 @@ export class AnthropicAIClient implements AIClient {
|
||||
body
|
||||
});
|
||||
|
||||
return response.data.content[0].text;
|
||||
if (response.ok && response.data?.content?.[0]?.text) {
|
||||
return ok(response.data.content[0].text);
|
||||
} else {
|
||||
return buildFailureFromAny(
|
||||
`Anthropic returned error code ${response.status} ${response.data?.error?.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { SHORT_DEFAULT_BRANCH_TEMPLATE, SHORT_DEFAULT_COMMIT_TEMPLATE } from '$lib/ai/prompts';
|
||||
import { map, type Result } from '$lib/result';
|
||||
import type { AIClient, ModelKind, Prompt } from '$lib/ai/types';
|
||||
import type { HttpClient } from '$lib/backend/httpClient';
|
||||
|
||||
@ -12,16 +13,19 @@ export class ButlerAIClient implements AIClient {
|
||||
private modelKind: ModelKind
|
||||
) {}
|
||||
|
||||
async evaluate(prompt: Prompt) {
|
||||
const response = await this.cloud.post<{ message: string }>('evaluate_prompt/predict.json', {
|
||||
body: {
|
||||
messages: prompt,
|
||||
max_tokens: 400,
|
||||
model_kind: this.modelKind
|
||||
},
|
||||
token: this.userToken
|
||||
});
|
||||
async evaluate(prompt: Prompt): Promise<Result<string, Error>> {
|
||||
const response = await this.cloud.postSafe<{ message: string }>(
|
||||
'evaluate_prompt/predict.json',
|
||||
{
|
||||
body: {
|
||||
messages: prompt,
|
||||
max_tokens: 400,
|
||||
model_kind: this.modelKind
|
||||
},
|
||||
token: this.userToken
|
||||
}
|
||||
);
|
||||
|
||||
return response.message;
|
||||
return map(response, ({ message }) => message);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { LONG_DEFAULT_BRANCH_TEMPLATE, LONG_DEFAULT_COMMIT_TEMPLATE } from '$lib/ai/prompts';
|
||||
import { MessageRole, type PromptMessage, type AIClient, type Prompt } from '$lib/ai/types';
|
||||
import { andThen, buildFailureFromAny, ok, wrap, wrapAsync, type Result } from '$lib/result';
|
||||
import { isNonEmptyObject } from '$lib/utils/typeguards';
|
||||
import { fetch, Body, Response } from '@tauri-apps/api/http';
|
||||
|
||||
@ -81,15 +82,22 @@ export class OllamaClient implements AIClient {
|
||||
private modelName: string
|
||||
) {}
|
||||
|
||||
async evaluate(prompt: Prompt) {
|
||||
async evaluate(prompt: Prompt): Promise<Result<string, Error>> {
|
||||
const messages = this.formatPrompt(prompt);
|
||||
const response = await this.chat(messages);
|
||||
const rawResponse = JSON.parse(response.message.content);
|
||||
if (!isOllamaChatMessageFormat(rawResponse)) {
|
||||
throw new Error('Invalid response: ' + response.message.content);
|
||||
}
|
||||
|
||||
return rawResponse.result;
|
||||
const responseResult = await this.chat(messages);
|
||||
|
||||
return andThen(responseResult, (response) => {
|
||||
const rawResponseResult = wrap<unknown, Error>(() => JSON.parse(response.message.content));
|
||||
|
||||
return andThen(rawResponseResult, (rawResponse) => {
|
||||
if (!isOllamaChatMessageFormat(rawResponse)) {
|
||||
return buildFailureFromAny('Invalid response: ' + response.message.content);
|
||||
}
|
||||
|
||||
return ok(rawResponse.result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -124,17 +132,19 @@ ${JSON.stringify(OLLAMA_CHAT_MESSAGE_FORMAT_SCHEMA, null, 2)}`
|
||||
* @param request - The OllamaChatRequest object containing the request details.
|
||||
* @returns A Promise that resolves to the Response object.
|
||||
*/
|
||||
private async fetchChat(request: OllamaChatRequest): Promise<Response<any>> {
|
||||
private async fetchChat(request: OllamaChatRequest): Promise<Result<Response<any>, Error>> {
|
||||
const url = new URL(OllamaAPEndpoint.Chat, this.endpoint);
|
||||
const body = Body.json(request);
|
||||
const result = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body
|
||||
});
|
||||
return result;
|
||||
return await wrapAsync(
|
||||
async () =>
|
||||
await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,13 +152,12 @@ ${JSON.stringify(OLLAMA_CHAT_MESSAGE_FORMAT_SCHEMA, null, 2)}`
|
||||
*
|
||||
* @param messages - An array of LLMChatMessage objects representing the chat messages.
|
||||
* @param options - Optional LLMRequestOptions object for specifying additional options.
|
||||
* @throws Error if the response is invalid.
|
||||
* @returns A Promise that resolves to an LLMResponse object representing the response from the LLM model.
|
||||
*/
|
||||
private async chat(
|
||||
messages: Prompt,
|
||||
options?: OllamaRequestOptions
|
||||
): Promise<OllamaChatResponse> {
|
||||
): Promise<Result<OllamaChatResponse, Error>> {
|
||||
const result = await this.fetchChat({
|
||||
model: this.modelName,
|
||||
stream: false,
|
||||
@ -157,10 +166,12 @@ ${JSON.stringify(OLLAMA_CHAT_MESSAGE_FORMAT_SCHEMA, null, 2)}`
|
||||
format: 'json'
|
||||
});
|
||||
|
||||
if (!isOllamaChatResponse(result.data)) {
|
||||
throw new Error('Invalid response\n' + JSON.stringify(result.data));
|
||||
}
|
||||
return andThen(result, (result) => {
|
||||
if (!isOllamaChatResponse(result.data)) {
|
||||
return buildFailureFromAny('Invalid response\n' + JSON.stringify(result.data));
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return ok(result.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { SHORT_DEFAULT_BRANCH_TEMPLATE, SHORT_DEFAULT_COMMIT_TEMPLATE } from '$lib/ai/prompts';
|
||||
import { andThen, buildFailureFromAny, ok, wrapAsync, type Result } from '$lib/result';
|
||||
import type { OpenAIModelName, Prompt, AIClient } from '$lib/ai/types';
|
||||
import type OpenAI from 'openai';
|
||||
import type { ChatCompletion } from 'openai/resources/index.mjs';
|
||||
|
||||
export class OpenAIClient implements AIClient {
|
||||
defaultCommitTemplate = SHORT_DEFAULT_COMMIT_TEMPLATE;
|
||||
@ -11,13 +13,21 @@ export class OpenAIClient implements AIClient {
|
||||
private openAI: OpenAI
|
||||
) {}
|
||||
|
||||
async evaluate(prompt: Prompt) {
|
||||
const response = await this.openAI.chat.completions.create({
|
||||
messages: prompt,
|
||||
model: this.modelName,
|
||||
max_tokens: 400
|
||||
async evaluate(prompt: Prompt): Promise<Result<string, Error>> {
|
||||
const responseResult = await wrapAsync<ChatCompletion, Error>(async () => {
|
||||
return await this.openAI.chat.completions.create({
|
||||
messages: prompt,
|
||||
model: this.modelName,
|
||||
max_tokens: 400
|
||||
});
|
||||
});
|
||||
|
||||
return response.choices[0].message.content || '';
|
||||
return andThen(responseResult, (response) => {
|
||||
if (response.choices[0]?.message.content) {
|
||||
return ok(response.choices[0]?.message.content);
|
||||
} else {
|
||||
return buildFailureFromAny('Open AI generated an empty message');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
type Prompt
|
||||
} from '$lib/ai/types';
|
||||
import { HttpClient } from '$lib/backend/httpClient';
|
||||
import * as toasts from '$lib/utils/toasts';
|
||||
import { buildFailureFromAny, ok, unwrap, type Result } from '$lib/result';
|
||||
import { Hunk } from '$lib/vbranches/types';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { expect, test, describe, vi } from 'vitest';
|
||||
@ -56,8 +56,8 @@ class DummyAIClient implements AIClient {
|
||||
defaultBranchTemplate = SHORT_DEFAULT_BRANCH_TEMPLATE;
|
||||
constructor(private response = 'lorem ipsum') {}
|
||||
|
||||
async evaluate(_prompt: Prompt) {
|
||||
return this.response;
|
||||
async evaluate(_prompt: Prompt): Promise<Result<string, Error>> {
|
||||
return ok(this.response);
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,16 +116,14 @@ describe.concurrent('AIService', () => {
|
||||
test('With default configuration, When a user token is provided. It returns ButlerAIClient', async () => {
|
||||
const aiService = buildDefaultAIService();
|
||||
|
||||
expect(await aiService.buildClient('token')).toBeInstanceOf(ButlerAIClient);
|
||||
expect(unwrap(await aiService.buildClient('token'))).toBeInstanceOf(ButlerAIClient);
|
||||
});
|
||||
|
||||
test('With default configuration, When a user is undefined. It returns undefined', async () => {
|
||||
const toastErrorSpy = vi.spyOn(toasts, 'error');
|
||||
const aiService = buildDefaultAIService();
|
||||
|
||||
expect(await aiService.buildClient()).toBe(undefined);
|
||||
expect(toastErrorSpy).toHaveBeenLastCalledWith(
|
||||
"When using GitButler's API to summarize code, you must be logged in"
|
||||
expect(await aiService.buildClient()).toStrictEqual(
|
||||
buildFailureFromAny("When using GitButler's API to summarize code, you must be logged in")
|
||||
);
|
||||
});
|
||||
|
||||
@ -137,11 +135,10 @@ describe.concurrent('AIService', () => {
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
|
||||
expect(await aiService.buildClient()).toBeInstanceOf(OpenAIClient);
|
||||
expect(unwrap(await aiService.buildClient())).toBeInstanceOf(OpenAIClient);
|
||||
});
|
||||
|
||||
test('When token is bring your own, When a openAI token is blank. It returns undefined', async () => {
|
||||
const toastErrorSpy = vi.spyOn(toasts, 'error');
|
||||
const gitConfig = new DummyGitConfigService({
|
||||
...defaultGitConfig,
|
||||
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn,
|
||||
@ -149,9 +146,10 @@ describe.concurrent('AIService', () => {
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
|
||||
expect(await aiService.buildClient()).toBe(undefined);
|
||||
expect(toastErrorSpy).toHaveBeenLastCalledWith(
|
||||
'When using OpenAI in a bring your own key configuration, you must provide a valid token'
|
||||
expect(await aiService.buildClient()).toStrictEqual(
|
||||
buildFailureFromAny(
|
||||
'When using OpenAI in a bring your own key configuration, you must provide a valid token'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@ -164,11 +162,10 @@ describe.concurrent('AIService', () => {
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
|
||||
expect(await aiService.buildClient()).toBeInstanceOf(AnthropicAIClient);
|
||||
expect(unwrap(await aiService.buildClient())).toBeInstanceOf(AnthropicAIClient);
|
||||
});
|
||||
|
||||
test('When ai provider is Anthropic, When token is bring your own, When an anthropic token is blank. It returns undefined', async () => {
|
||||
const toastErrorSpy = vi.spyOn(toasts, 'error');
|
||||
const gitConfig = new DummyGitConfigService({
|
||||
...defaultGitConfig,
|
||||
[GitAIConfigKey.ModelProvider]: ModelKind.Anthropic,
|
||||
@ -177,9 +174,10 @@ describe.concurrent('AIService', () => {
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
|
||||
expect(await aiService.buildClient()).toBe(undefined);
|
||||
expect(toastErrorSpy).toHaveBeenLastCalledWith(
|
||||
'When using Anthropic in a bring your own key configuration, you must provide a valid token'
|
||||
expect(await aiService.buildClient()).toStrictEqual(
|
||||
buildFailureFromAny(
|
||||
'When using Anthropic in a bring your own key configuration, you must provide a valid token'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -188,9 +186,13 @@ describe.concurrent('AIService', () => {
|
||||
test('When buildModel returns undefined, it returns undefined', async () => {
|
||||
const aiService = buildDefaultAIService();
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue((async () => undefined)());
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => buildFailureFromAny('Failed to build'))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeCommit({ hunks: exampleHunks })).toBe(undefined);
|
||||
expect(await aiService.summarizeCommit({ hunks: exampleHunks })).toStrictEqual(
|
||||
buildFailureFromAny('Failed to build')
|
||||
);
|
||||
});
|
||||
|
||||
test('When the AI returns a single line commit message, it returns it unchanged', async () => {
|
||||
@ -199,10 +201,12 @@ describe.concurrent('AIService', () => {
|
||||
const clientResponse = 'single line commit';
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => new DummyAIClient(clientResponse))()
|
||||
(async () => ok<AIClient, Error>(new DummyAIClient(clientResponse)))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeCommit({ hunks: exampleHunks })).toBe('single line commit');
|
||||
expect(await aiService.summarizeCommit({ hunks: exampleHunks })).toStrictEqual(
|
||||
ok('single line commit')
|
||||
);
|
||||
});
|
||||
|
||||
test('When the AI returns a title and body that is split by a single new line, it replaces it with two', async () => {
|
||||
@ -211,10 +215,12 @@ describe.concurrent('AIService', () => {
|
||||
const clientResponse = 'one\nnew line';
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => new DummyAIClient(clientResponse))()
|
||||
(async () => ok<AIClient, Error>(new DummyAIClient(clientResponse)))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeCommit({ hunks: exampleHunks })).toBe('one\n\nnew line');
|
||||
expect(await aiService.summarizeCommit({ hunks: exampleHunks })).toStrictEqual(
|
||||
ok('one\n\nnew line')
|
||||
);
|
||||
});
|
||||
|
||||
test('When the commit is in brief mode, When the AI returns a title and body, it takes just the title', async () => {
|
||||
@ -223,12 +229,12 @@ describe.concurrent('AIService', () => {
|
||||
const clientResponse = 'one\nnew line';
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => new DummyAIClient(clientResponse))()
|
||||
(async () => ok<AIClient, Error>(new DummyAIClient(clientResponse)))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeCommit({ hunks: exampleHunks, useBriefStyle: true })).toBe(
|
||||
'one'
|
||||
);
|
||||
expect(
|
||||
await aiService.summarizeCommit({ hunks: exampleHunks, useBriefStyle: true })
|
||||
).toStrictEqual(ok('one'));
|
||||
});
|
||||
});
|
||||
|
||||
@ -236,9 +242,13 @@ describe.concurrent('AIService', () => {
|
||||
test('When buildModel returns undefined, it returns undefined', async () => {
|
||||
const aiService = buildDefaultAIService();
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue((async () => undefined)());
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => buildFailureFromAny('Failed to build client'))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toBe(undefined);
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toStrictEqual(
|
||||
buildFailureFromAny('Failed to build client')
|
||||
);
|
||||
});
|
||||
|
||||
test('When the AI client returns a string with spaces, it replaces them with hypens', async () => {
|
||||
@ -247,10 +257,12 @@ describe.concurrent('AIService', () => {
|
||||
const clientResponse = 'with spaces included';
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => new DummyAIClient(clientResponse))()
|
||||
(async () => ok<AIClient, Error>(new DummyAIClient(clientResponse)))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toBe('with-spaces-included');
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toStrictEqual(
|
||||
ok('with-spaces-included')
|
||||
);
|
||||
});
|
||||
|
||||
test('When the AI client returns multiple lines, it replaces them with hypens', async () => {
|
||||
@ -259,11 +271,11 @@ describe.concurrent('AIService', () => {
|
||||
const clientResponse = 'with\nnew\nlines\nincluded';
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => new DummyAIClient(clientResponse))()
|
||||
(async () => ok<AIClient, Error>(new DummyAIClient(clientResponse)))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toBe(
|
||||
'with-new-lines-included'
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toStrictEqual(
|
||||
ok('with-new-lines-included')
|
||||
);
|
||||
});
|
||||
|
||||
@ -273,11 +285,11 @@ describe.concurrent('AIService', () => {
|
||||
const clientResponse = 'with\nnew lines\nincluded';
|
||||
|
||||
vi.spyOn(aiService, 'buildClient').mockReturnValue(
|
||||
(async () => new DummyAIClient(clientResponse))()
|
||||
(async () => ok<AIClient, Error>(new DummyAIClient(clientResponse)))()
|
||||
);
|
||||
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toBe(
|
||||
'with-new-lines-included'
|
||||
expect(await aiService.summarizeBranch({ hunks: exampleHunks })).toStrictEqual(
|
||||
ok('with-new-lines-included')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -14,8 +14,8 @@ import {
|
||||
MessageRole,
|
||||
type Prompt
|
||||
} from '$lib/ai/types';
|
||||
import { buildFailureFromAny, isFailure, ok, type Result } from '$lib/result';
|
||||
import { splitMessage } from '$lib/utils/commitMessage';
|
||||
import * as toasts from '$lib/utils/toasts';
|
||||
import OpenAI from 'openai';
|
||||
import type { GitConfigService } from '$lib/backend/gitConfigService';
|
||||
import type { HttpClient } from '$lib/backend/httpClient';
|
||||
@ -189,21 +189,22 @@ export class AIService {
|
||||
// This optionally returns a summarizer. There are a few conditions for how this may occur
|
||||
// Firstly, if the user has opted to use the GB API and isn't logged in, it will return undefined
|
||||
// Secondly, if the user has opted to bring their own key but hasn't provided one, it will return undefined
|
||||
async buildClient(userToken?: string): Promise<undefined | AIClient> {
|
||||
async buildClient(userToken?: string): Promise<Result<AIClient, Error>> {
|
||||
const modelKind = await this.getModelKind();
|
||||
|
||||
if (await this.usingGitButlerAPI()) {
|
||||
if (!userToken) {
|
||||
toasts.error("When using GitButler's API to summarize code, you must be logged in");
|
||||
return;
|
||||
return buildFailureFromAny(
|
||||
"When using GitButler's API to summarize code, you must be logged in"
|
||||
);
|
||||
}
|
||||
return new ButlerAIClient(this.cloud, userToken, modelKind);
|
||||
return ok(new ButlerAIClient(this.cloud, userToken, modelKind));
|
||||
}
|
||||
|
||||
if (modelKind === ModelKind.Ollama) {
|
||||
const ollamaEndpoint = await this.getOllamaEndpoint();
|
||||
const ollamaModelName = await this.getOllamaModelName();
|
||||
return new OllamaClient(ollamaEndpoint, ollamaModelName);
|
||||
return ok(new OllamaClient(ollamaEndpoint, ollamaModelName));
|
||||
}
|
||||
|
||||
if (modelKind === ModelKind.OpenAI) {
|
||||
@ -211,14 +212,13 @@ export class AIService {
|
||||
const openAIKey = await this.getOpenAIKey();
|
||||
|
||||
if (!openAIKey) {
|
||||
toasts.error(
|
||||
return buildFailureFromAny(
|
||||
'When using OpenAI in a bring your own key configuration, you must provide a valid token'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const openAI = new OpenAI({ apiKey: openAIKey, dangerouslyAllowBrowser: true });
|
||||
return new OpenAIClient(openAIModelName, openAI);
|
||||
return ok(new OpenAIClient(openAIModelName, openAI));
|
||||
}
|
||||
|
||||
if (modelKind === ModelKind.Anthropic) {
|
||||
@ -226,14 +226,15 @@ export class AIService {
|
||||
const anthropicKey = await this.getAnthropicKey();
|
||||
|
||||
if (!anthropicKey) {
|
||||
toasts.error(
|
||||
return buildFailureFromAny(
|
||||
'When using Anthropic in a bring your own key configuration, you must provide a valid token'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return new AnthropicAIClient(anthropicKey, anthropicModelName);
|
||||
return ok(new AnthropicAIClient(anthropicKey, anthropicModelName));
|
||||
}
|
||||
|
||||
return buildFailureFromAny('Failed to build ai client');
|
||||
}
|
||||
|
||||
async summarizeCommit({
|
||||
@ -242,9 +243,10 @@ export class AIService {
|
||||
useBriefStyle = false,
|
||||
commitTemplate,
|
||||
userToken
|
||||
}: SummarizeCommitOpts) {
|
||||
const aiClient = await this.buildClient(userToken);
|
||||
if (!aiClient) return;
|
||||
}: SummarizeCommitOpts): Promise<Result<string, Error>> {
|
||||
const aiClientResult = await this.buildClient(userToken);
|
||||
if (isFailure(aiClientResult)) return aiClientResult;
|
||||
const aiClient = aiClientResult.value;
|
||||
|
||||
const diffLengthLimit = await this.getDiffLengthLimitConsideringAPI();
|
||||
const defaultedCommitTemplate = commitTemplate || aiClient.defaultCommitTemplate;
|
||||
@ -272,19 +274,26 @@ export class AIService {
|
||||
};
|
||||
});
|
||||
|
||||
let message = await aiClient.evaluate(prompt);
|
||||
const messageResult = await aiClient.evaluate(prompt);
|
||||
if (isFailure(messageResult)) return messageResult;
|
||||
let message = messageResult.value;
|
||||
|
||||
if (useBriefStyle) {
|
||||
message = message.split('\n')[0];
|
||||
}
|
||||
|
||||
const { title, description } = splitMessage(message);
|
||||
return description ? `${title}\n\n${description}` : title;
|
||||
return ok(description ? `${title}\n\n${description}` : title);
|
||||
}
|
||||
|
||||
async summarizeBranch({ hunks, branchTemplate, userToken = undefined }: SummarizeBranchOpts) {
|
||||
const aiClient = await this.buildClient(userToken);
|
||||
if (!aiClient) return;
|
||||
async summarizeBranch({
|
||||
hunks,
|
||||
branchTemplate,
|
||||
userToken = undefined
|
||||
}: SummarizeBranchOpts): Promise<Result<string, Error>> {
|
||||
const aiClientResult = await this.buildClient(userToken);
|
||||
if (isFailure(aiClientResult)) return aiClientResult;
|
||||
const aiClient = aiClientResult.value;
|
||||
|
||||
const diffLengthLimit = await this.getDiffLengthLimitConsideringAPI();
|
||||
const defaultedBranchTemplate = branchTemplate || aiClient.defaultBranchTemplate;
|
||||
@ -299,7 +308,10 @@ export class AIService {
|
||||
};
|
||||
});
|
||||
|
||||
const message = await aiClient.evaluate(prompt);
|
||||
return message.replaceAll(' ', '-').replaceAll('\n', '-');
|
||||
const messageResult = await aiClient.evaluate(prompt);
|
||||
if (isFailure(messageResult)) return messageResult;
|
||||
const message = messageResult.value;
|
||||
|
||||
return ok(message.replaceAll(' ', '-').replaceAll('\n', '-'));
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import type { Persisted } from '$lib/persisted/persisted';
|
||||
import type { Result } from '$lib/result';
|
||||
|
||||
export enum ModelKind {
|
||||
OpenAI = 'openai',
|
||||
@ -33,7 +34,7 @@ export interface PromptMessage {
|
||||
export type Prompt = PromptMessage[];
|
||||
|
||||
export interface AIClient {
|
||||
evaluate(prompt: Prompt): Promise<string>;
|
||||
evaluate(prompt: Prompt): Promise<Result<string, Error>>;
|
||||
|
||||
defaultBranchTemplate: Prompt;
|
||||
defaultCommitTemplate: Prompt;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { wrapAsync } from '$lib/result';
|
||||
import { PUBLIC_API_BASE_URL } from '$env/static/public';
|
||||
|
||||
export const API_URL = new URL('/api/', PUBLIC_API_BASE_URL);
|
||||
@ -47,21 +48,41 @@ export class HttpClient {
|
||||
return await this.request<T>(path, { ...opts, method: 'GET' });
|
||||
}
|
||||
|
||||
async getSafe<T>(path: string, opts?: Omit<RequestOptions, 'body'>) {
|
||||
return await wrapAsync<T, Error>(async () => await this.get<T>(path, opts));
|
||||
}
|
||||
|
||||
async post<T>(path: string, opts?: RequestOptions) {
|
||||
return await this.request<T>(path, { ...opts, method: 'POST' });
|
||||
}
|
||||
|
||||
async postSafe<T>(path: string, opts?: RequestOptions) {
|
||||
return await wrapAsync<T, Error>(async () => await this.post<T>(path, opts));
|
||||
}
|
||||
|
||||
async put<T>(path: string, opts?: RequestOptions) {
|
||||
return await this.request<T>(path, { ...opts, method: 'PUT' });
|
||||
}
|
||||
|
||||
async putSafe<T>(path: string, opts?: RequestOptions) {
|
||||
return await wrapAsync<T, Error>(async () => await this.put<T>(path, opts));
|
||||
}
|
||||
|
||||
async patch<T>(path: string, opts?: RequestOptions) {
|
||||
return await this.request<T>(path, { ...opts, method: 'PATCH' });
|
||||
}
|
||||
|
||||
async patchSafe<T>(path: string, opts?: RequestOptions) {
|
||||
return await wrapAsync<T, Error>(async () => await this.patch<T>(path, opts));
|
||||
}
|
||||
|
||||
async delete<T>(path: string, opts?: RequestOptions) {
|
||||
return await this.request<T>(path, { ...opts, method: 'DELETE' });
|
||||
}
|
||||
|
||||
async deleteSafe<T>(path: string, opts?: RequestOptions) {
|
||||
return await wrapAsync<T, Error>(async () => await this.delete<T>(path, opts));
|
||||
}
|
||||
}
|
||||
|
||||
function getApiUrl(path: string) {
|
||||
|
@ -17,6 +17,7 @@
|
||||
import BranchFiles from '$lib/file/BranchFiles.svelte';
|
||||
import { showError } from '$lib/notifications/toasts';
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import { isFailure } from '$lib/result';
|
||||
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
|
||||
import Resizer from '$lib/shared/Resizer.svelte';
|
||||
import { User } from '$lib/stores/user';
|
||||
@ -64,21 +65,25 @@
|
||||
|
||||
const hunks = branch.files.flatMap((f) => f.hunks);
|
||||
|
||||
try {
|
||||
const prompt = promptService.selectedBranchPrompt(project.id);
|
||||
const message = await aiService.summarizeBranch({
|
||||
hunks,
|
||||
userToken: $user?.access_token,
|
||||
branchTemplate: prompt
|
||||
});
|
||||
const prompt = promptService.selectedBranchPrompt(project.id);
|
||||
const messageResult = await aiService.summarizeBranch({
|
||||
hunks,
|
||||
userToken: $user?.access_token,
|
||||
branchTemplate: prompt
|
||||
});
|
||||
|
||||
if (message && message !== branch.name) {
|
||||
branch.name = message;
|
||||
branchController.updateBranchName(branch.id, branch.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showError('Failed to generate branch name', e);
|
||||
if (isFailure(messageResult)) {
|
||||
console.error(messageResult.failure);
|
||||
showError('Failed to generate branch name', messageResult.failure);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const message = messageResult.value;
|
||||
|
||||
if (message && message !== branch.name) {
|
||||
branch.name = message;
|
||||
branchController.updateBranchName(branch.id, branch.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,17 +104,8 @@
|
||||
branch.files?.length === 0 ||
|
||||
!branch.active}
|
||||
/>
|
||||
|
||||
<ContextMenuItem label="Allow rebasing" on:click={toggleAllowRebasing}>
|
||||
<Toggle
|
||||
small
|
||||
slot="control"
|
||||
bind:checked={allowRebasing}
|
||||
on:click={toggleAllowRebasing}
|
||||
help="Having this enabled permits commit amending and reordering after a branch has been pushed, which would subsequently require force pushing"
|
||||
/>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
|
||||
<ContextMenuSection>
|
||||
<ContextMenuItem
|
||||
label="Set remote branch name"
|
||||
@ -126,6 +117,19 @@
|
||||
}}
|
||||
/>
|
||||
</ContextMenuSection>
|
||||
|
||||
<ContextMenuSection>
|
||||
<ContextMenuItem label="Allow rebasing" on:click={toggleAllowRebasing}>
|
||||
<Toggle
|
||||
small
|
||||
slot="control"
|
||||
bind:checked={allowRebasing}
|
||||
on:click={toggleAllowRebasing}
|
||||
help="Having this enabled permits commit amending and reordering after a branch has been pushed, which would subsequently require force pushing"
|
||||
/>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
|
||||
<ContextMenuSection>
|
||||
<ContextMenuItem
|
||||
label="Create branch to the left"
|
||||
|
@ -247,24 +247,21 @@
|
||||
/* border-bottom: 1px solid var(--clr-border-2); */
|
||||
|
||||
--base-top-margin: 8px;
|
||||
--base-icon-top: 16px;
|
||||
--base-unfolded: 48px;
|
||||
--base-row-height: 20px;
|
||||
--base-row-height-unfolded: 48px;
|
||||
|
||||
--base-icon-top: -8px;
|
||||
|
||||
--avatar-first-top: 50px;
|
||||
--avatar-top: 16px;
|
||||
}
|
||||
|
||||
/* .commit-group {
|
||||
padding-right: 14px;
|
||||
padding-left: 8px;
|
||||
} */
|
||||
|
||||
/* BASE ROW */
|
||||
|
||||
.base-row-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 20px;
|
||||
height: var(--base-row-height);
|
||||
|
||||
overflow: hidden;
|
||||
transition: height var(--transition-medium);
|
||||
@ -281,8 +278,8 @@
|
||||
}
|
||||
|
||||
.base-row-container_unfolded {
|
||||
height: var(--base-unfolded);
|
||||
--base-icon-top: 20px;
|
||||
--base-row-height: var(--base-row-height-unfolded);
|
||||
--base-icon-top: -3px;
|
||||
|
||||
& .base-row__text {
|
||||
opacity: 1;
|
||||
@ -293,7 +290,7 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--clr-border-3);
|
||||
min-height: calc(var(--base-unfolded) - var(--base-top-margin));
|
||||
min-height: calc(var(--base-row-height-unfolded) - var(--base-top-margin));
|
||||
margin-top: var(--base-top-margin);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
@ -11,6 +11,7 @@
|
||||
projectCommitGenerationUseEmojis
|
||||
} from '$lib/config/config';
|
||||
import { showError } from '$lib/notifications/toasts';
|
||||
import { isFailure } from '$lib/result';
|
||||
import Checkbox from '$lib/shared/Checkbox.svelte';
|
||||
import DropDownButton from '$lib/shared/DropDownButton.svelte';
|
||||
import Icon from '$lib/shared/Icon.svelte';
|
||||
@ -75,27 +76,35 @@
|
||||
}
|
||||
|
||||
aiLoading = true;
|
||||
try {
|
||||
const prompt = promptService.selectedCommitPrompt(project.id);
|
||||
console.log(prompt);
|
||||
const generatedMessage = await aiService.summarizeCommit({
|
||||
hunks,
|
||||
useEmojiStyle: $commitGenerationUseEmojis,
|
||||
useBriefStyle: $commitGenerationExtraConcise,
|
||||
userToken: $user?.access_token,
|
||||
commitTemplate: prompt
|
||||
});
|
||||
|
||||
if (generatedMessage) {
|
||||
commitMessage = generatedMessage;
|
||||
} else {
|
||||
throw new Error('Prompt generated no response');
|
||||
}
|
||||
} catch (e: any) {
|
||||
showError('Failed to generate commit message', e);
|
||||
} finally {
|
||||
const prompt = promptService.selectedCommitPrompt(project.id);
|
||||
|
||||
const generatedMessageResult = await aiService.summarizeCommit({
|
||||
hunks,
|
||||
useEmojiStyle: $commitGenerationUseEmojis,
|
||||
useBriefStyle: $commitGenerationExtraConcise,
|
||||
userToken: $user?.access_token,
|
||||
commitTemplate: prompt
|
||||
});
|
||||
|
||||
if (isFailure(generatedMessageResult)) {
|
||||
showError('Failed to generate commit message', generatedMessageResult.failure);
|
||||
aiLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedMessage = generatedMessageResult.value;
|
||||
|
||||
if (generatedMessage) {
|
||||
commitMessage = generatedMessage;
|
||||
} else {
|
||||
const errorMessage = 'Prompt generated no response';
|
||||
showError(errorMessage, undefined);
|
||||
aiLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
aiLoading = false;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@ -152,7 +161,10 @@
|
||||
const value = e.currentTarget.value;
|
||||
if (e.key === 'Backspace' && value.length === 0) {
|
||||
e.preventDefault();
|
||||
titleTextArea?.focus();
|
||||
if (titleTextArea) {
|
||||
titleTextArea?.focus();
|
||||
titleTextArea.selectionStart = titleTextArea.textLength;
|
||||
}
|
||||
useAutoHeight(e.currentTarget);
|
||||
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey) && value.length === 0) {
|
||||
// select previous textarea on cmd+a if this textarea is empty
|
||||
@ -222,7 +234,6 @@
|
||||
padding: 0 0 48px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
animation: expand-box 0.2s ease forwards;
|
||||
/* props to animate on mount */
|
||||
max-height: 40px;
|
||||
|
@ -3,22 +3,6 @@ import { BehaviorSubject } from 'rxjs';
|
||||
import { expect, test, describe } from 'vitest';
|
||||
|
||||
const exampleRemoteUrls = [
|
||||
'ssh://user@host.xz:123/org/repo.git/',
|
||||
'ssh://user@host.xz/org/repo.git/',
|
||||
'ssh://host.xz:123/org/repo.git/',
|
||||
'ssh://host.xz:123/org/repo',
|
||||
'ssh://host.xz/org/repo.git/',
|
||||
'ssh://host.xz/org/repo.git',
|
||||
'ssh://host.xz/org/repo',
|
||||
'ssh://user@host.xz/org/repo.git/',
|
||||
'ssh://user@host.xz/org/repo.git',
|
||||
'ssh://user@host.xz/org/repo',
|
||||
'host.xz:org/repo.git/',
|
||||
'host.xz:org/repo.git',
|
||||
'host.xz:org/repo',
|
||||
'user@host.xz:org/repo.git/',
|
||||
'user@host.xz:org/repo.git',
|
||||
'user@host.xz:org/repo',
|
||||
'git@github.com:org/repo.git/',
|
||||
'git@github.com:org/repo.git',
|
||||
'git@github.com:org/repo',
|
||||
|
@ -70,7 +70,9 @@ export class GitHubService {
|
||||
combineLatest([accessToken$, remoteUrl$])
|
||||
.pipe(
|
||||
tap(([accessToken, remoteUrl]) => {
|
||||
if (!accessToken) {
|
||||
// We check the remote url since GitHub is currently enabled at the account
|
||||
// level rather than project level.
|
||||
if (!accessToken || !remoteUrl?.includes('github.com')) {
|
||||
return of();
|
||||
}
|
||||
this._octokit = new Octokit({
|
||||
@ -91,8 +93,8 @@ export class GitHubService {
|
||||
combineLatest([this.reload$, accessToken$, remoteUrl$])
|
||||
.pipe(
|
||||
tap(() => this.error$.next(undefined)),
|
||||
switchMap(([reload, _token, remoteUrl]) => {
|
||||
if (!this.isEnabled || !remoteUrl) return EMPTY;
|
||||
switchMap(([reload, _token, _remoteUrl]) => {
|
||||
if (!this._octokit || !this._owner) return EMPTY;
|
||||
const prs = this.fetchPrs(!!reload?.skipCache);
|
||||
this.fresh$.next();
|
||||
return prs;
|
||||
|
99
app/src/lib/result.ts
Normal file
99
app/src/lib/result.ts
Normal file
@ -0,0 +1,99 @@
|
||||
export class Panic extends Error {}
|
||||
|
||||
export type OkVariant<Ok> = {
|
||||
ok: true;
|
||||
value: Ok;
|
||||
};
|
||||
|
||||
export type FailureVariant<Err> = {
|
||||
ok: false;
|
||||
failure: Err;
|
||||
};
|
||||
|
||||
export type Result<Ok, Err> = OkVariant<Ok> | FailureVariant<Err>;
|
||||
|
||||
export function isOk<Ok, Err>(
|
||||
subject: OkVariant<Ok> | FailureVariant<Err>
|
||||
): subject is OkVariant<Ok> {
|
||||
return subject.ok;
|
||||
}
|
||||
|
||||
export function isFailure<Ok, Err>(
|
||||
subject: OkVariant<Ok> | FailureVariant<Err>
|
||||
): subject is FailureVariant<Err> {
|
||||
return !subject.ok;
|
||||
}
|
||||
|
||||
export function ok<Ok, Err>(value: Ok): Result<Ok, Err> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export function failure<Ok, Err>(value: Err): Result<Ok, Err> {
|
||||
return { ok: false, failure: value };
|
||||
}
|
||||
|
||||
export function buildFailureFromAny<Ok>(value: any): Result<Ok, Error> {
|
||||
if (value instanceof Error) {
|
||||
return failure(value);
|
||||
} else {
|
||||
return failure(new Error(String(value)));
|
||||
}
|
||||
}
|
||||
|
||||
export function wrap<Ok, Err>(subject: () => Ok): Result<Ok, Err> {
|
||||
try {
|
||||
return ok(subject());
|
||||
} catch (e) {
|
||||
return failure(e as Err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function wrapAsync<Ok, Err>(subject: () => Promise<Ok>): Promise<Result<Ok, Err>> {
|
||||
try {
|
||||
return ok(await subject());
|
||||
} catch (e) {
|
||||
return failure(e as Err);
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrap<Ok, Err>(subject: Result<Ok, Err>): Ok {
|
||||
if (isOk(subject)) {
|
||||
return subject.value;
|
||||
} else {
|
||||
if (subject.failure instanceof Error) {
|
||||
throw subject.failure;
|
||||
} else {
|
||||
throw new Panic(String(subject.failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapOr<Ok, Err, Or>(subject: Result<Ok, Err>, or: Or): Ok | Or {
|
||||
if (isOk(subject)) {
|
||||
return subject.value;
|
||||
} else {
|
||||
return or;
|
||||
}
|
||||
}
|
||||
|
||||
export function map<Ok, Err, NewOk>(
|
||||
subject: Result<Ok, Err>,
|
||||
transformation: (ok: Ok) => NewOk
|
||||
): Result<NewOk, Err> {
|
||||
if (isOk(subject)) {
|
||||
return ok(transformation(subject.value));
|
||||
} else {
|
||||
return subject;
|
||||
}
|
||||
}
|
||||
|
||||
export function andThen<Ok, Err, NewOk>(
|
||||
subject: Result<Ok, Err>,
|
||||
transformation: (ok: Ok) => Result<NewOk, Err>
|
||||
): Result<NewOk, Err> {
|
||||
if (isOk(subject)) {
|
||||
return transformation(subject.value);
|
||||
} else {
|
||||
return subject;
|
||||
}
|
||||
}
|
@ -87,17 +87,21 @@ export class VirtualBranchService {
|
||||
.map(async (b) => {
|
||||
const upstreamName = b.upstream?.name;
|
||||
if (upstreamName) {
|
||||
const data = await getRemoteBranchData(projectId, upstreamName);
|
||||
const commits = data.commits;
|
||||
commits.forEach((uc) => {
|
||||
const match = b.commits.find((c) => commitCompare(uc, c));
|
||||
if (match) {
|
||||
match.relatedTo = uc;
|
||||
uc.relatedTo = match;
|
||||
}
|
||||
});
|
||||
linkAsParentChildren(commits);
|
||||
b.upstreamData = data;
|
||||
try {
|
||||
const data = await getRemoteBranchData(projectId, upstreamName);
|
||||
const commits = data.commits;
|
||||
commits.forEach((uc) => {
|
||||
const match = b.commits.find((c) => commitCompare(uc, c));
|
||||
if (match) {
|
||||
match.relatedTo = uc;
|
||||
uc.relatedTo = match;
|
||||
}
|
||||
});
|
||||
linkAsParentChildren(commits);
|
||||
b.upstreamData = data;
|
||||
} catch (e: any) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
return b;
|
||||
})
|
||||
|
@ -34,7 +34,6 @@ rand = "0.8.5"
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.12.4", features = ["json"] }
|
||||
resolve-path = "0.1.0"
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["std"]}
|
||||
serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
|
||||
sha2 = "0.10.8"
|
||||
|
@ -231,3 +231,30 @@ impl AnyhowContextExt for anyhow::Error {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A way to mark errors using `[anyhow::Context::context]` for later retrieval, e.g. to know
|
||||
/// that a certain even happened.
|
||||
///
|
||||
/// Note that the display implementation is visible to users in logs, so it's a bit 'special'
|
||||
/// to signify its marker status.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Marker {
|
||||
/// Invalid state was detected, making the repository invalid for operation.
|
||||
VerificationFailure,
|
||||
/// An indicator for a conflict in the project.
|
||||
///
|
||||
/// See usages for details on what these conflicts can be.
|
||||
ProjectConflict,
|
||||
/// An indicator that a branch conflicted during applying to the workspace.
|
||||
BranchConflict,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Marker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Marker::VerificationFailure => f.write_str("<verification-failed>"),
|
||||
Marker::ProjectConflict => f.write_str("<project-conflict>"),
|
||||
Marker::BranchConflict => f.write_str("<branch-conflict>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,20 +43,6 @@ impl<T> Default for Id<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> rusqlite::types::FromSql for Id<T> {
|
||||
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
|
||||
Uuid::parse_str(value.as_str()?)
|
||||
.map(Into::into)
|
||||
.map_err(|error| rusqlite::types::FromSqlError::Other(Box::new(error)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> rusqlite::ToSql for Id<T> {
|
||||
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
|
||||
Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PartialEq for Id<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.eq(&other.0)
|
||||
|
@ -27,6 +27,7 @@ pub mod ops;
|
||||
pub mod path;
|
||||
pub mod project_repository;
|
||||
pub mod projects;
|
||||
pub mod rebase;
|
||||
pub mod remotes;
|
||||
pub mod ssh;
|
||||
pub mod storage;
|
||||
|
@ -15,7 +15,7 @@ use crate::{
|
||||
ssh, users,
|
||||
virtual_branches::{Branch, BranchId},
|
||||
};
|
||||
use crate::{git::RepositoryExt, virtual_branches::errors::Marker};
|
||||
use crate::{error::Marker, git::RepositoryExt};
|
||||
|
||||
pub struct Repository {
|
||||
git_repository: git2::Repository,
|
||||
|
99
crates/gitbutler-core/src/rebase/mod.rs
Normal file
99
crates/gitbutler-core/src/rebase/mod.rs
Normal file
@ -0,0 +1,99 @@
|
||||
use crate::{error::Marker, git::CommitExt, git::RepositoryExt, project_repository};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use bstr::ByteSlice;
|
||||
|
||||
/// cherry-pick based rebase, which handles empty commits
|
||||
/// this function takes a commit range and generates a Vector of commit oids
|
||||
/// and then passes them to `cherry_rebase_group` to rebase them onto the target commit
|
||||
pub fn cherry_rebase(
|
||||
project_repository: &project_repository::Repository,
|
||||
target_commit_oid: git2::Oid,
|
||||
start_commit_oid: git2::Oid,
|
||||
end_commit_oid: git2::Oid,
|
||||
) -> Result<Option<git2::Oid>> {
|
||||
// get a list of the commits to rebase
|
||||
let mut ids_to_rebase = project_repository.l(
|
||||
end_commit_oid,
|
||||
project_repository::LogUntil::Commit(start_commit_oid),
|
||||
)?;
|
||||
|
||||
if ids_to_rebase.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let new_head_id =
|
||||
cherry_rebase_group(project_repository, target_commit_oid, &mut ids_to_rebase)?;
|
||||
|
||||
Ok(Some(new_head_id))
|
||||
}
|
||||
|
||||
/// takes a vector of commit oids and rebases them onto a target commit and returns the
|
||||
/// new head commit oid if it's successful
|
||||
/// the difference between this and a libgit2 based rebase is that this will successfully
|
||||
/// rebase empty commits (two commits with identical trees)
|
||||
pub fn cherry_rebase_group(
|
||||
project_repository: &project_repository::Repository,
|
||||
target_commit_oid: git2::Oid,
|
||||
ids_to_rebase: &mut [git2::Oid],
|
||||
) -> Result<git2::Oid> {
|
||||
ids_to_rebase.reverse();
|
||||
// now, rebase unchanged commits onto the new commit
|
||||
let commits_to_rebase = ids_to_rebase
|
||||
.iter()
|
||||
.map(|oid| project_repository.repo().find_commit(oid.to_owned()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("failed to read commits to rebase")?;
|
||||
|
||||
let new_head_id = commits_to_rebase
|
||||
.into_iter()
|
||||
.fold(
|
||||
project_repository
|
||||
.repo()
|
||||
.find_commit(target_commit_oid)
|
||||
.context("failed to find new commit"),
|
||||
|head, to_rebase| {
|
||||
let head = head?;
|
||||
|
||||
let mut cherrypick_index = project_repository
|
||||
.repo()
|
||||
.cherrypick_commit(&to_rebase, &head, 0, None)
|
||||
.context("failed to cherry pick")?;
|
||||
|
||||
if cherrypick_index.has_conflicts() {
|
||||
return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict);
|
||||
}
|
||||
|
||||
let merge_tree_oid = cherrypick_index
|
||||
.write_tree_to(project_repository.repo())
|
||||
.context("failed to write merge tree")?;
|
||||
|
||||
let merge_tree = project_repository
|
||||
.repo()
|
||||
.find_tree(merge_tree_oid)
|
||||
.context("failed to find merge tree")?;
|
||||
|
||||
let change_id = to_rebase.change_id();
|
||||
|
||||
let commit_oid = project_repository
|
||||
.repo()
|
||||
.commit_with_signature(
|
||||
None,
|
||||
&to_rebase.author(),
|
||||
&to_rebase.committer(),
|
||||
&to_rebase.message_bstr().to_str_lossy(),
|
||||
&merge_tree,
|
||||
&[&head],
|
||||
change_id.as_deref(),
|
||||
)
|
||||
.context("failed to create commit")?;
|
||||
|
||||
project_repository
|
||||
.repo()
|
||||
.find_commit(commit_oid)
|
||||
.context("failed to find commit")
|
||||
},
|
||||
)?
|
||||
.id();
|
||||
|
||||
Ok(new_head_id)
|
||||
}
|
@ -19,7 +19,13 @@ impl Controller {
|
||||
}
|
||||
|
||||
pub fn get_user(&self) -> anyhow::Result<Option<User>> {
|
||||
self.storage.get().context("failed to get user")
|
||||
match self.storage.get().context("failed to get user") {
|
||||
Ok(user) => Ok(user),
|
||||
Err(err) => {
|
||||
self.storage.delete().ok();
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_user(&self, user: &User) -> anyhow::Result<()> {
|
||||
|
@ -11,13 +11,13 @@ use super::{
|
||||
},
|
||||
target, BranchId, RemoteCommit, VirtualBranchHunk, VirtualBranchesHandle,
|
||||
};
|
||||
use crate::{git::RepositoryExt, virtual_branches::errors::Marker};
|
||||
use crate::{error::Marker, git::RepositoryExt, rebase::cherry_rebase};
|
||||
use crate::{
|
||||
git::{self, diff},
|
||||
project_repository::{self, LogUntil},
|
||||
projects::FetchResult,
|
||||
users,
|
||||
virtual_branches::{branch::BranchOwnershipClaims, cherry_rebase},
|
||||
virtual_branches::branch::BranchOwnershipClaims,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Clone)]
|
||||
|
@ -1,26 +0,0 @@
|
||||
/// A way to mark errors using `[anyhow::Context::context]` for later retrieval, e.g. to know
|
||||
/// that a certain even happened.
|
||||
///
|
||||
/// Note that the display implementation is visible to users in logs, so it's a bit 'special'
|
||||
/// to signify its marker status.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Marker {
|
||||
/// Invalid state was detected, making the repository invalid for operation.
|
||||
VerificationFailure,
|
||||
/// An indicator for a conflict in the project.
|
||||
///
|
||||
/// See usages for details on what these conflicts can be.
|
||||
ProjectConflict,
|
||||
/// An indicator that a branch conflicted during applying to the workspace.
|
||||
BranchConflict,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Marker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Marker::VerificationFailure => f.write_str("<verification-failed>"),
|
||||
Marker::ProjectConflict => f.write_str("<project-conflict>"),
|
||||
Marker::BranchConflict => f.write_str("<branch-conflict>"),
|
||||
}
|
||||
}
|
||||
}
|
@ -5,8 +5,8 @@ use bstr::ByteSlice;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use super::VirtualBranchesHandle;
|
||||
use crate::error::Marker;
|
||||
use crate::git::RepositoryExt;
|
||||
use crate::virtual_branches::errors::Marker;
|
||||
use crate::{
|
||||
git::{self, CommitExt},
|
||||
project_repository::{self, conflicts, LogUntil},
|
||||
|
@ -2,8 +2,6 @@ pub mod branch;
|
||||
pub use branch::{Branch, BranchId};
|
||||
pub mod target;
|
||||
|
||||
pub mod errors;
|
||||
|
||||
mod files;
|
||||
pub use files::*;
|
||||
|
||||
|
@ -26,12 +26,13 @@ use super::{
|
||||
branch_to_remote_branch, target, RemoteBranch, VirtualBranchesHandle,
|
||||
};
|
||||
use crate::error::Code;
|
||||
use crate::error::Marker;
|
||||
use crate::git::diff::GitHunk;
|
||||
use crate::git::diff::{diff_files_into_hunks, trees, FileDiff};
|
||||
use crate::git::{CommitExt, RepositoryExt};
|
||||
use crate::rebase::{cherry_rebase, cherry_rebase_group};
|
||||
use crate::time::now_since_unix_epoch_ms;
|
||||
use crate::virtual_branches::branch::HunkHash;
|
||||
use crate::virtual_branches::errors::Marker;
|
||||
use crate::{
|
||||
dedup::{dedup, dedup_fmt},
|
||||
git::{
|
||||
@ -2228,27 +2229,6 @@ pub fn write_tree_onto_tree(
|
||||
Ok(tree_oid)
|
||||
}
|
||||
|
||||
fn _print_tree(repo: &git2::Repository, tree: &git2::Tree) -> Result<()> {
|
||||
println!("tree id: {}", tree.id());
|
||||
for entry in tree {
|
||||
println!(
|
||||
" entry: {} {}",
|
||||
entry.name().unwrap_or_default(),
|
||||
entry.id()
|
||||
);
|
||||
// get entry contents
|
||||
let object = entry.to_object(repo).context("failed to get object")?;
|
||||
let blob = object.as_blob().context("failed to get blob")?;
|
||||
// convert content to string
|
||||
if let Ok(content) = std::str::from_utf8(blob.content()) {
|
||||
println!(" blob: {}", content);
|
||||
} else {
|
||||
println!(" blob: BINARY");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn commit(
|
||||
project_repository: &project_repository::Repository,
|
||||
@ -3201,102 +3181,6 @@ pub fn undo_commit(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// cherry-pick based rebase, which handles empty commits
|
||||
// this function takes a commit range and generates a Vector of commit oids
|
||||
// and then passes them to `cherry_rebase_group` to rebase them onto the target commit
|
||||
pub fn cherry_rebase(
|
||||
project_repository: &project_repository::Repository,
|
||||
target_commit_oid: git2::Oid,
|
||||
start_commit_oid: git2::Oid,
|
||||
end_commit_oid: git2::Oid,
|
||||
) -> Result<Option<git2::Oid>> {
|
||||
// get a list of the commits to rebase
|
||||
let mut ids_to_rebase = project_repository.l(
|
||||
end_commit_oid,
|
||||
project_repository::LogUntil::Commit(start_commit_oid),
|
||||
)?;
|
||||
|
||||
if ids_to_rebase.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let new_head_id =
|
||||
cherry_rebase_group(project_repository, target_commit_oid, &mut ids_to_rebase)?;
|
||||
|
||||
Ok(Some(new_head_id))
|
||||
}
|
||||
|
||||
// takes a vector of commit oids and rebases them onto a target commit and returns the
|
||||
// new head commit oid if it's successful
|
||||
// the difference between this and a libgit2 based rebase is that this will successfully
|
||||
// rebase empty commits (two commits with identical trees)
|
||||
fn cherry_rebase_group(
|
||||
project_repository: &project_repository::Repository,
|
||||
target_commit_oid: git2::Oid,
|
||||
ids_to_rebase: &mut [git2::Oid],
|
||||
) -> Result<git2::Oid> {
|
||||
ids_to_rebase.reverse();
|
||||
// now, rebase unchanged commits onto the new commit
|
||||
let commits_to_rebase = ids_to_rebase
|
||||
.iter()
|
||||
.map(|oid| project_repository.repo().find_commit(oid.to_owned()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("failed to read commits to rebase")?;
|
||||
|
||||
let new_head_id = commits_to_rebase
|
||||
.into_iter()
|
||||
.fold(
|
||||
project_repository
|
||||
.repo()
|
||||
.find_commit(target_commit_oid)
|
||||
.context("failed to find new commit"),
|
||||
|head, to_rebase| {
|
||||
let head = head?;
|
||||
|
||||
let mut cherrypick_index = project_repository
|
||||
.repo()
|
||||
.cherrypick_commit(&to_rebase, &head, 0, None)
|
||||
.context("failed to cherry pick")?;
|
||||
|
||||
if cherrypick_index.has_conflicts() {
|
||||
return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict);
|
||||
}
|
||||
|
||||
let merge_tree_oid = cherrypick_index
|
||||
.write_tree_to(project_repository.repo())
|
||||
.context("failed to write merge tree")?;
|
||||
|
||||
let merge_tree = project_repository
|
||||
.repo()
|
||||
.find_tree(merge_tree_oid)
|
||||
.context("failed to find merge tree")?;
|
||||
|
||||
let change_id = to_rebase.change_id();
|
||||
|
||||
let commit_oid = project_repository
|
||||
.repo()
|
||||
.commit_with_signature(
|
||||
None,
|
||||
&to_rebase.author(),
|
||||
&to_rebase.committer(),
|
||||
&to_rebase.message_bstr().to_str_lossy(),
|
||||
&merge_tree,
|
||||
&[&head],
|
||||
change_id.as_deref(),
|
||||
)
|
||||
.context("failed to create commit")?;
|
||||
|
||||
project_repository
|
||||
.repo()
|
||||
.find_commit(commit_oid)
|
||||
.context("failed to find commit")
|
||||
},
|
||||
)?
|
||||
.id();
|
||||
|
||||
Ok(new_head_id)
|
||||
}
|
||||
|
||||
pub fn cherry_pick(
|
||||
project_repository: &project_repository::Repository,
|
||||
branch_id: BranchId,
|
||||
|
@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{fs, path, str::FromStr};
|
||||
|
||||
use gitbutler_core::virtual_branches::errors::Marker;
|
||||
use gitbutler_core::error::Marker;
|
||||
use gitbutler_core::{
|
||||
git,
|
||||
projects::{self, Project, ProjectId},
|
||||
|
@ -2,6 +2,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gitbutler_core::error::Marker;
|
||||
use gitbutler_core::ops::entry::{OperationKind, SnapshotDetails};
|
||||
use gitbutler_core::projects::ProjectId;
|
||||
use gitbutler_core::synchronize::sync_with_gitbutler;
|
||||
@ -101,8 +102,8 @@ impl Handler {
|
||||
}
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.downcast_ref::<virtual_branches::errors::Marker>(),
|
||||
Some(virtual_branches::errors::Marker::VerificationFailure)
|
||||
err.downcast_ref::<Marker>(),
|
||||
Some(Marker::VerificationFailure)
|
||||
) =>
|
||||
{
|
||||
Ok(())
|
||||
|
@ -29,7 +29,7 @@
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
||||
margin-top: -8px;
|
||||
margin-top: var(--base-icon-top);
|
||||
margin-bottom: -8px;
|
||||
margin-left: -9px;
|
||||
margin-right: -7px;
|
||||
@ -40,6 +40,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
transition: margin-top var(--transition-medium);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
Loading…
Reference in New Issue
Block a user