snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
24
crates/yaak-git/Cargo.toml
Normal file
24
crates/yaak-git/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "yaak-git"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9.34"
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util"] }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||
url = "2"
|
||||
yaak-common = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-sync = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
35
crates/yaak-git/bindings/gen_git.ts
generated
Normal file
35
crates/yaak-git/bindings/gen_git.ts
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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 BranchDeleteResult = { "type": "success", message: string, } | { "type": "not_fully_merged" };
|
||||
|
||||
export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||
|
||||
export type GitAuthor = { name: string | null, email: string | null, };
|
||||
|
||||
export type GitBranchInfo = { path: string, headRef: string | null, headRefShorthand: string | null, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
||||
|
||||
export type GitCommit = { oid: string, author: GitAuthor, when: string, message: string | null, };
|
||||
|
||||
export type GitFileDiff = { original: string, modified: string, };
|
||||
|
||||
export type GitRemote = { name: string, url: string | null, };
|
||||
|
||||
export type GitStatus = "untracked" | "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,
|
||||
/**
|
||||
* The status directory relative to the repo root ("" when it IS the root).
|
||||
* Useful for displaying entry paths relative to the sync directory
|
||||
*/
|
||||
relaDir: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
||||
|
||||
export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
|
||||
|
||||
export type GitWorktreeStatusEntry = { relaPath: string, modelId: string | null, status: GitStatus, staged: boolean, };
|
||||
|
||||
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
|
||||
|
||||
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||
179
crates/yaak-git/bindings/gen_models.ts
generated
Normal file
179
crates/yaak-git/bindings/gen_models.ts
generated
Normal file
@@ -0,0 +1,179 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
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;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
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<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
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;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
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;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
424
crates/yaak-git/index.ts
Normal file
424
crates/yaak-git/index.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
|
||||
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BranchDeleteResult,
|
||||
CloneResult,
|
||||
GitBranchInfo,
|
||||
GitCommit,
|
||||
GitFileDiff,
|
||||
GitRemote,
|
||||
GitStatusSummary,
|
||||
GitWorktreeStatus,
|
||||
PullResult,
|
||||
PushResult,
|
||||
} from "./bindings/gen_git";
|
||||
import { showToast } from "@yaakapp/yaak-client/lib/toast";
|
||||
|
||||
export * from "./bindings/gen_git";
|
||||
export * from "./bindings/gen_models";
|
||||
|
||||
export interface GitCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type DivergedStrategy = "force_reset" | "merge" | "cancel";
|
||||
|
||||
export type UncommittedChangesStrategy = "reset" | "cancel";
|
||||
|
||||
interface GitWatchResult {
|
||||
unlistenEvent: string;
|
||||
}
|
||||
|
||||
export interface GitCallbacks {
|
||||
addRemote: () => Promise<GitRemote | null>;
|
||||
promptCredentials: (
|
||||
result: Extract<PushResult, { type: "needs_credentials" }>,
|
||||
) => Promise<GitCredentials | null>;
|
||||
promptDiverged: (result: Extract<PullResult, { type: "diverged" }>) => Promise<DivergedStrategy>;
|
||||
promptUncommittedChanges: () => Promise<UncommittedChangesStrategy>;
|
||||
forceSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
|
||||
|
||||
function gitWorktreeStatusQueryKey(dir?: string, refreshKey?: string) {
|
||||
return refreshKey == null
|
||||
? (["git", "worktree_status", dir] as const)
|
||||
: (["git", "worktree_status", dir, refreshKey] as const);
|
||||
}
|
||||
|
||||
export function invalidateGitWorktreeStatus(dir?: string) {
|
||||
return queryClient.invalidateQueries({ queryKey: gitWorktreeStatusQueryKey(dir) });
|
||||
}
|
||||
|
||||
export function useGitWorktreeStatus(dir: string, refreshKey?: string) {
|
||||
return useQuery<GitWorktreeStatus, string>({
|
||||
queryKey: gitWorktreeStatusQueryKey(dir, refreshKey),
|
||||
queryFn: () => platform.rpc("cmd_git_worktree_status", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function watchGitWorktreeStatus(dir: string, callback: (status: GitWorktreeStatus) => void) {
|
||||
const handle = platform.rpcStream<GitWatchResult, GitWorktreeStatus>(
|
||||
"cmd_git_watch_worktree_status",
|
||||
{ dir },
|
||||
callback,
|
||||
);
|
||||
|
||||
void handle
|
||||
.then(({ result }) => {
|
||||
addGitWatchKey(result.unlistenEvent);
|
||||
})
|
||||
.catch(console.debug);
|
||||
|
||||
return () =>
|
||||
handle
|
||||
.then(async ({ result, unlisten }) => {
|
||||
unlistenGitWatcher(result.unlistenEvent);
|
||||
unlisten();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function useGitFetchAll(dir: string, refreshKey?: string) {
|
||||
return useQuery<void, string>({
|
||||
queryKey: ["git", "fetch_all", dir, refreshKey],
|
||||
queryFn: () => platform.rpc("cmd_git_fetch_all", { dir }),
|
||||
refetchInterval: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function useGitBranchInfoQuery(dir: string, refreshKey?: string, fetchAllUpdatedAt?: number) {
|
||||
return useQuery<GitBranchInfo, string>({
|
||||
refetchOnMount: true,
|
||||
queryKey: ["git", "branch_info", dir, refreshKey, fetchAllUpdatedAt],
|
||||
queryFn: () => platform.rpc("cmd_git_branch_info", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGitBranchInfo(dir: string, refreshKey?: string) {
|
||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||
return useGitBranchInfoQuery(dir, refreshKey, fetchAll.dataUpdatedAt);
|
||||
}
|
||||
|
||||
export function useGitLog(dir: string, refreshKey?: string, relaPath?: string) {
|
||||
return useQuery<GitCommit[], string>({
|
||||
queryKey: ["git", "log", dir, refreshKey, relaPath],
|
||||
queryFn: () =>
|
||||
relaPath == null
|
||||
? platform.rpc("cmd_git_log", { dir })
|
||||
: platform.rpc("cmd_git_log_for_file", { dir, relaPath }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGitFileDiffForCommit(
|
||||
dir: string,
|
||||
relaPath: string,
|
||||
commitOid: string | null | undefined,
|
||||
) {
|
||||
return useQuery<GitFileDiff, string>({
|
||||
enabled: commitOid != null,
|
||||
queryKey: ["git", "file_diff_for_commit", dir, relaPath, commitOid],
|
||||
queryFn: () => {
|
||||
if (commitOid == null) throw new Error("Missing commit oid");
|
||||
return platform.rpc("cmd_git_file_diff_for_commit", { dir, relaPath, commitOid });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
||||
const mutations = useGitMutations(dir, callbacks);
|
||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||
|
||||
return [
|
||||
{
|
||||
remotes: useQuery<GitRemote[], string>({
|
||||
queryKey: ["git", "remotes", dir, refreshKey],
|
||||
queryFn: () => getRemotes(dir),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
log: useGitLog(dir, refreshKey),
|
||||
status: useQuery<GitStatusSummary, string>({
|
||||
refetchOnMount: true,
|
||||
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
|
||||
queryFn: () => platform.rpc("cmd_git_status", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
},
|
||||
mutations,
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useGitMutations(dir: string, callbacks: GitCallbacks) {
|
||||
return useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
||||
}
|
||||
|
||||
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
const push = async () => {
|
||||
const remotes = await getRemotes(dir);
|
||||
if (remotes.length === 0) {
|
||||
const remote = await callbacks.addRemote();
|
||||
if (remote == null) throw new Error("No remote found");
|
||||
}
|
||||
|
||||
const result = await platform.rpc<PushResult>("cmd_git_push", { dir });
|
||||
if (result.type !== "needs_credentials") return result;
|
||||
|
||||
// Needs credentials, prompt for them
|
||||
const creds = await callbacks.promptCredentials(result);
|
||||
if (creds == null) throw new Error("Canceled");
|
||||
|
||||
await platform.rpc("cmd_git_add_credential", {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
// Push again
|
||||
return platform.rpc<PushResult>("cmd_git_push", { dir });
|
||||
};
|
||||
|
||||
const handleError = (err: unknown) => {
|
||||
showToast({
|
||||
id: String(err),
|
||||
message: String(err),
|
||||
color: "danger",
|
||||
timeout: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
init: createFastMutation<void, string, void>({
|
||||
mutationKey: ["git", "init"],
|
||||
mutationFn: () => platform.rpc("cmd_git_initialize", { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
add: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||
mutationKey: ["git", "add", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_add", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
addRemote: createFastMutation<GitRemote, string, GitRemote>({
|
||||
mutationKey: ["git", "add-remote"],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_add_remote", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
rmRemote: createFastMutation<void, string, { name: string }>({
|
||||
mutationKey: ["git", "rm-remote", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_rm_remote", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
createBranch: createFastMutation<void, string, { branch: string; base?: string }>({
|
||||
mutationKey: ["git", "branch", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_branch", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
mergeBranch: createFastMutation<void, string, { branch: string }>({
|
||||
mutationKey: ["git", "merge", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_merge_branch", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
deleteBranch: createFastMutation<
|
||||
BranchDeleteResult,
|
||||
string,
|
||||
{ branch: string; force?: boolean }
|
||||
>({
|
||||
mutationKey: ["git", "delete-branch", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_delete_branch", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
deleteRemoteBranch: createFastMutation<void, string, { branch: string }>({
|
||||
mutationKey: ["git", "delete-remote-branch", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_delete_remote_branch", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
renameBranch: createFastMutation<void, string, { oldName: string; newName: string }>({
|
||||
mutationKey: ["git", "rename-branch", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_rename_branch", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
|
||||
mutationKey: ["git", "checkout", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_checkout", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
commit: createFastMutation<void, string, { message: string }>({
|
||||
mutationKey: ["git", "commit", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_commit", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
commitAndPush: createFastMutation<PushResult, string, { message: string }>({
|
||||
mutationKey: ["git", "commit_push", dir],
|
||||
mutationFn: async (args) => {
|
||||
await platform.rpc("cmd_git_commit", { dir, ...args });
|
||||
return push();
|
||||
},
|
||||
onSuccess,
|
||||
}),
|
||||
|
||||
push: createFastMutation<PushResult, string, void>({
|
||||
mutationKey: ["git", "push", dir],
|
||||
mutationFn: push,
|
||||
onSuccess,
|
||||
}),
|
||||
pull: createFastMutation<PullResult, string, void>({
|
||||
mutationKey: ["git", "pull", dir],
|
||||
async mutationFn() {
|
||||
const result = await platform.rpc<PullResult>("cmd_git_pull", { dir });
|
||||
|
||||
if (result.type === "needs_credentials") {
|
||||
const creds = await callbacks.promptCredentials(result);
|
||||
if (creds == null) throw new Error("Canceled");
|
||||
|
||||
await platform.rpc("cmd_git_add_credential", {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
// Pull again after credentials
|
||||
return platform.rpc<PullResult>("cmd_git_pull", { dir });
|
||||
}
|
||||
|
||||
if (result.type === "uncommitted_changes") {
|
||||
void callbacks
|
||||
.promptUncommittedChanges()
|
||||
.then(async (strategy) => {
|
||||
if (strategy === "cancel") return;
|
||||
|
||||
await platform.rpc("cmd_git_reset_changes", { dir });
|
||||
return platform.rpc<PullResult>("cmd_git_pull", { dir });
|
||||
})
|
||||
.then(async () => {
|
||||
await onSuccess();
|
||||
await callbacks.forceSync();
|
||||
}, handleError);
|
||||
}
|
||||
|
||||
if (result.type === "diverged") {
|
||||
void callbacks
|
||||
.promptDiverged(result)
|
||||
.then((strategy) => {
|
||||
if (strategy === "cancel") return;
|
||||
|
||||
if (strategy === "force_reset") {
|
||||
return platform.rpc<PullResult>("cmd_git_pull_force_reset", {
|
||||
dir,
|
||||
remote: result.remote,
|
||||
branch: result.branch,
|
||||
});
|
||||
}
|
||||
|
||||
return platform.rpc<PullResult>("cmd_git_pull_merge", {
|
||||
dir,
|
||||
remote: result.remote,
|
||||
branch: result.branch,
|
||||
});
|
||||
})
|
||||
.then(async () => {
|
||||
await onSuccess();
|
||||
await callbacks.forceSync();
|
||||
}, handleError);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess,
|
||||
}),
|
||||
unstage: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||
mutationKey: ["git", "unstage", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_unstage", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
resetChanges: createFastMutation<void, string, void>({
|
||||
mutationKey: ["git", "reset-changes", dir],
|
||||
mutationFn: () => platform.rpc("cmd_git_reset_changes", { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
restore: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||
mutationKey: ["git", "restore", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_restore_files", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
restoreFileFromCommit: createFastMutation<
|
||||
void,
|
||||
string,
|
||||
{ commitOid: string; relaPath: string }
|
||||
>({
|
||||
mutationKey: ["git", "restore-file-from-commit", dir],
|
||||
mutationFn: (args) => platform.rpc("cmd_git_restore_file_from_commit", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
} as const;
|
||||
};
|
||||
|
||||
async function getRemotes(dir: string) {
|
||||
return platform.rpc<GitRemote[]>("cmd_git_remotes", { dir });
|
||||
}
|
||||
|
||||
function unlistenGitWatcher(unlistenEvent: string) {
|
||||
void platform.emit(unlistenEvent).then(() => {
|
||||
removeGitWatchKey(unlistenEvent);
|
||||
});
|
||||
}
|
||||
|
||||
function getGitWatchKeys() {
|
||||
return sessionStorage.getItem("git-worktree-watchers")?.split(",").filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
function setGitWatchKeys(keys: string[]) {
|
||||
sessionStorage.setItem("git-worktree-watchers", keys.join(","));
|
||||
}
|
||||
|
||||
function addGitWatchKey(key: string) {
|
||||
const keys = getGitWatchKeys();
|
||||
setGitWatchKeys([...keys, key]);
|
||||
}
|
||||
|
||||
function removeGitWatchKey(key: string) {
|
||||
const keys = getGitWatchKeys();
|
||||
setGitWatchKeys(keys.filter((k) => k !== key));
|
||||
}
|
||||
|
||||
const gitWatchKeys = getGitWatchKeys();
|
||||
if (gitWatchKeys.length > 0) {
|
||||
gitWatchKeys.forEach(unlistenGitWatcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a git repository, prompting for credentials if needed.
|
||||
*/
|
||||
export async function gitClone(
|
||||
url: string,
|
||||
dir: string,
|
||||
promptCredentials: (args: {
|
||||
url: string;
|
||||
error: string | null;
|
||||
}) => Promise<GitCredentials | null>,
|
||||
): Promise<CloneResult> {
|
||||
const result = await platform.rpc<CloneResult>("cmd_git_clone", { url, dir });
|
||||
if (result.type !== "needs_credentials") return result;
|
||||
|
||||
// Prompt for credentials
|
||||
const creds = await promptCredentials({
|
||||
url: result.url,
|
||||
error: result.error,
|
||||
});
|
||||
if (creds == null) return { type: "cancelled" };
|
||||
|
||||
// Store credentials and retry
|
||||
await platform.rpc("cmd_git_add_credential", {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
return platform.rpc<CloneResult>("cmd_git_clone", { url, dir });
|
||||
}
|
||||
6
crates/yaak-git/package.json
Normal file
6
crates/yaak-git/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/git",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
16
crates/yaak-git/src/add.rs
Normal file
16
crates/yaak-git/src/add.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use git2::IndexAddOption;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
|
||||
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(())
|
||||
}
|
||||
17
crates/yaak-git/src/binary.rs
Normal file
17
crates/yaak-git/src/binary.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::error::Error::GitNotFound;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
use yaak_common::command::new_checked_command;
|
||||
|
||||
/// Create a git command that runs in the specified directory
|
||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||
let mut cmd = new_binary_command_global().await?;
|
||||
cmd.arg("-C").arg(dir);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Create a git command without a specific directory (for global operations)
|
||||
pub(crate) async fn new_binary_command_global() -> Result<Command> {
|
||||
new_checked_command("git", "--version").await.map_err(|_| GitNotFound)
|
||||
}
|
||||
153
crates/yaak-git/src/branch.rs
Normal file
153
crates/yaak-git/src/branch.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum BranchDeleteResult {
|
||||
Success { message: String },
|
||||
NotFullyMerged,
|
||||
}
|
||||
|
||||
pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
||||
let branch_name = branch_name.trim_start_matches("origin/");
|
||||
|
||||
let mut args = vec!["checkout"];
|
||||
if force {
|
||||
args.push("--force");
|
||||
}
|
||||
args.push(branch_name);
|
||||
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git checkout: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to checkout: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(branch_name.to_string())
|
||||
}
|
||||
|
||||
pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> {
|
||||
let mut cmd = new_binary_command(dir).await?;
|
||||
cmd.arg("branch").arg(name);
|
||||
if let Some(base_branch) = base {
|
||||
cmd.arg(base_branch);
|
||||
}
|
||||
|
||||
let out =
|
||||
cmd.output().await.map_err(|e| GenericError(format!("failed to run git branch: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to create branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result<BranchDeleteResult> {
|
||||
let mut cmd = new_binary_command(dir).await?;
|
||||
|
||||
let out =
|
||||
if force { cmd.args(["branch", "-D", name]) } else { cmd.args(["branch", "-d", name]) }
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git branch -d: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() && stderr.to_lowercase().contains("not fully merged") {
|
||||
return Ok(BranchDeleteResult::NotFullyMerged);
|
||||
}
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to delete branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(BranchDeleteResult::Success { message: combined })
|
||||
}
|
||||
|
||||
pub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["merge", name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
// Check for merge conflicts
|
||||
if combined.to_lowercase().contains("conflict") {
|
||||
return Err(GenericError(
|
||||
"Merge conflicts detected. Please resolve them manually.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
// Remote branch names come in as "origin/branch-name", extract the branch name
|
||||
let branch_name = name.trim_start_matches("origin/");
|
||||
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["push", "origin", "--delete", branch_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git push --delete: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to delete remote branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["branch", "-m", old_name, new_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git branch -m: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to rename branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
53
crates/yaak-git/src/clone.rs
Normal file
53
crates/yaak-git/src/clone.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum CloneResult {
|
||||
Success,
|
||||
Cancelled,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
}
|
||||
|
||||
pub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||
let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| GenericError(format!("Failed to create directory: {e}")))?;
|
||||
let mut cmd = new_binary_command(parent).await?;
|
||||
cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||
|
||||
let out =
|
||||
cmd.output().await.map_err(|e| GenericError(format!("failed to run git clone: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
let combined_lower = combined.to_lowercase();
|
||||
|
||||
info!("Cloned status={}: {combined}", out.status);
|
||||
|
||||
if !out.status.success() {
|
||||
// Check for credentials error
|
||||
if combined_lower.contains("could not read") {
|
||||
return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });
|
||||
}
|
||||
if combined_lower.contains("unable to access")
|
||||
|| combined_lower.contains("authentication failed")
|
||||
{
|
||||
return Ok(CloneResult::NeedsCredentials {
|
||||
url: url.to_string(),
|
||||
error: Some(combined.to_string()),
|
||||
});
|
||||
}
|
||||
return Err(GenericError(format!("Failed to clone: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(CloneResult::Success)
|
||||
}
|
||||
106
crates/yaak-git/src/commit.rs
Normal file
106
crates/yaak-git/src/commit.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::repository::open_repo;
|
||||
use crate::status::repo_relative_dir;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
|
||||
/// Commit the staged changes within `dir` (their current worktree content,
|
||||
/// matching what the commit dialog displays). Scoping the commit to the sync
|
||||
/// directory means files staged outside of it (e.g. elsewhere in a containing
|
||||
/// monorepo) are never swept into a Yaak commit — they stay staged for the
|
||||
/// user's own next commit.
|
||||
pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
|
||||
// Run git from the repo root: command-line pathspecs resolve relative to
|
||||
// the working directory, and staged paths are repo-root-relative
|
||||
let (workdir, rela_dir) = {
|
||||
let repo = open_repo(dir)?;
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| GenericError("Repository has no worktree".to_string()))?
|
||||
.to_path_buf();
|
||||
(workdir, repo_relative_dir(&repo, dir))
|
||||
};
|
||||
|
||||
let staged = staged_files(&workdir, rela_dir.as_deref()).await?;
|
||||
if staged.is_empty() {
|
||||
return Err(GenericError("No staged changes to commit".to_string()));
|
||||
}
|
||||
|
||||
let mut cmd = new_binary_command(&workdir).await?;
|
||||
// --literal-pathspecs: the staged paths are exact files, never patterns
|
||||
cmd.arg("--literal-pathspecs");
|
||||
cmd.args(["commit", "--message", message, "--"]);
|
||||
cmd.args(staged);
|
||||
|
||||
let out = cmd.output().await?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = stdout + stderr;
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to commit: {}", combined)));
|
||||
}
|
||||
|
||||
info!("Committed to {dir:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repo-relative paths of staged changes, limited to `rela_dir` when given.
|
||||
/// Must be run from the repo root so the pathspec resolves correctly. Uses
|
||||
/// -z for NUL separation so paths with special characters come through
|
||||
/// unquoted, --no-renames so staged renames list both sides, and
|
||||
/// --literal-pathspecs so a scope dir with glob characters isn't a pattern
|
||||
async fn staged_files(workdir: &Path, rela_dir: Option<&str>) -> crate::error::Result<Vec<String>> {
|
||||
let mut cmd = new_binary_command(workdir).await?;
|
||||
cmd.arg("--literal-pathspecs");
|
||||
cmd.args(["diff", "--cached", "--name-only", "--no-renames", "-z"]);
|
||||
if let Some(rela_dir) = rela_dir {
|
||||
cmd.args(["--", rela_dir]);
|
||||
}
|
||||
let out = cmd.output().await?;
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(GenericError(format!("Failed to list staged files: {}", stderr)));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&out.stdout)
|
||||
.split('\0')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(String::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_staged_files_scoped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(tmp.path()).unwrap();
|
||||
|
||||
// Scope dir name doubles as a glob pattern; sync1 is its glob decoy
|
||||
let sync_dir = tmp.path().join("sync[1]");
|
||||
std::fs::create_dir(&sync_dir).unwrap();
|
||||
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
|
||||
std::fs::create_dir(tmp.path().join("sync1")).unwrap();
|
||||
std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "decoy").unwrap();
|
||||
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
|
||||
|
||||
let mut index = repo.index().unwrap();
|
||||
index.add_path(Path::new("sync[1]/yaak.req_1.yaml")).unwrap();
|
||||
index.add_path(Path::new("sync1/decoy.txt")).unwrap();
|
||||
index.add_path(Path::new("outside.txt")).unwrap();
|
||||
index.write().unwrap();
|
||||
|
||||
let scoped = staged_files(tmp.path(), Some("sync[1]")).await.unwrap();
|
||||
assert_eq!(scoped, vec!["sync[1]/yaak.req_1.yaml"]);
|
||||
|
||||
let all = staged_files(tmp.path(), None).await.unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
}
|
||||
}
|
||||
44
crates/yaak-git/src/credential.rs
Normal file
44
crates/yaak-git/src/credential.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::binary::new_binary_command_global;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
|
||||
pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> {
|
||||
let url = Url::parse(remote_url)
|
||||
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
|
||||
let protocol = url.scheme();
|
||||
let host = url.host_str().unwrap();
|
||||
let path = Some(url.path());
|
||||
|
||||
let mut child = new_binary_command_global()
|
||||
.await?
|
||||
.args(["credential", "approve"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
stdin.write_all(format!("protocol={}\n", protocol).as_bytes()).await?;
|
||||
stdin.write_all(format!("host={}\n", host).as_bytes()).await?;
|
||||
if let Some(path) = path {
|
||||
if !path.is_empty() {
|
||||
stdin
|
||||
.write_all(format!("path={}\n", path.trim_start_matches('/')).as_bytes())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
stdin.write_all(format!("username={}\n", username).as_bytes()).await?;
|
||||
stdin.write_all(format!("password={}\n", password).as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?; // blank line terminator
|
||||
}
|
||||
|
||||
let status = child.wait().await?;
|
||||
if !status.success() {
|
||||
return Err(GenericError("Failed to approve git credential".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
64
crates/yaak-git/src/error.rs
Normal file
64
crates/yaak-git/src/error.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
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(transparent)]
|
||||
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("JSON error: {0}")]
|
||||
JsonParseError(#[from] serde_json::Error),
|
||||
|
||||
#[error("UTF8 error: {0}")]
|
||||
Utf8ConversionError(#[from] FromUtf8Error),
|
||||
|
||||
#[error("Git error: {0}")]
|
||||
GenericError(String),
|
||||
|
||||
#[error("'git' not found. Please ensure it's installed and available in $PATH")]
|
||||
GitNotFound,
|
||||
|
||||
#[error("Credentials required: {0}")]
|
||||
CredentialsRequiredError(String),
|
||||
|
||||
#[error("No default remote found")]
|
||||
NoDefaultRemoteFound,
|
||||
|
||||
#[error("No remotes found for repo")]
|
||||
NoRemotesFound,
|
||||
|
||||
#[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>;
|
||||
22
crates/yaak-git/src/fetch.rs
Normal file
22
crates/yaak-git/src/fetch.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn git_fetch_all(dir: &Path) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["fetch", "--all", "--prune", "--tags"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = stdout + stderr;
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to fetch: {}", combined)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
14
crates/yaak-git/src/init.rs
Normal file
14
crates/yaak-git/src/init.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
|
||||
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(())
|
||||
}
|
||||
44
crates/yaak-git/src/lib.rs
Normal file
44
crates/yaak-git/src/lib.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
mod add;
|
||||
mod binary;
|
||||
mod branch;
|
||||
mod clone;
|
||||
mod commit;
|
||||
mod credential;
|
||||
pub mod error;
|
||||
mod fetch;
|
||||
mod init;
|
||||
mod log;
|
||||
|
||||
mod pull;
|
||||
mod push;
|
||||
mod remotes;
|
||||
mod repository;
|
||||
mod reset;
|
||||
mod restore;
|
||||
mod status;
|
||||
mod unstage;
|
||||
mod util;
|
||||
|
||||
// Re-export all git functions for external use
|
||||
pub use add::git_add;
|
||||
pub use branch::{
|
||||
BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,
|
||||
git_delete_remote_branch, git_merge_branch, git_rename_branch,
|
||||
};
|
||||
pub use clone::{CloneResult, git_clone};
|
||||
pub use commit::git_commit;
|
||||
pub use credential::git_add_credential;
|
||||
pub use fetch::git_fetch_all;
|
||||
pub use init::git_init;
|
||||
pub use log::{GitCommit, GitFileDiff, git_file_diff_for_commit, git_log, git_log_for_file};
|
||||
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
||||
pub use push::{PushResult, git_push};
|
||||
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
||||
pub use repository::{GitRepositoryPaths, git_path_is_ignored, git_repository_paths};
|
||||
pub use reset::git_reset_changes;
|
||||
pub use restore::{git_restore, git_restore_file_from_commit};
|
||||
pub use status::{
|
||||
GitBranchInfo, GitStatusSummary, GitWorktreeStatus, git_branch_info, git_status,
|
||||
git_worktree_status,
|
||||
};
|
||||
pub use unstage::git_unstage;
|
||||
145
crates/yaak-git/src/log.rs
Normal file
145
crates/yaak-git/src/log.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use crate::repository::open_repo;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitCommit {
|
||||
pub oid: String,
|
||||
pub author: GitAuthor,
|
||||
pub when: DateTime<Utc>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitAuthor {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitFileDiff {
|
||||
pub original: String,
|
||||
pub modified: String,
|
||||
}
|
||||
|
||||
pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||
git_log_inner(dir, None)
|
||||
}
|
||||
|
||||
pub fn git_log_for_file(dir: &Path, rela_path: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||
git_log_inner(dir, Some(rela_path))
|
||||
}
|
||||
|
||||
fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::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));
|
||||
if let Some(rela_path) = rela_path {
|
||||
let touches_path = filter_try!(commit_touches_path(&repo, &commit, rela_path));
|
||||
if !touches_path {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let author = commit.author();
|
||||
Some(GitCommit {
|
||||
oid: oid.to_string(),
|
||||
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_file_diff_for_commit(
|
||||
dir: &Path,
|
||||
commit_oid: &str,
|
||||
rela_path: &Path,
|
||||
) -> crate::error::Result<GitFileDiff> {
|
||||
let repo = open_repo(dir)?;
|
||||
let oid = git2::Oid::from_str(commit_oid)?;
|
||||
let commit = repo.find_commit(oid)?;
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
||||
|
||||
Ok(GitFileDiff {
|
||||
original: blob_text_at_path(&repo, old_tree.as_ref(), rela_path)?,
|
||||
modified: blob_text_at_path(&repo, Some(&new_tree), rela_path)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn commit_touches_path(
|
||||
repo: &git2::Repository,
|
||||
commit: &git2::Commit,
|
||||
rela_path: &Path,
|
||||
) -> crate::error::Result<bool> {
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
||||
|
||||
let mut opts = git2::DiffOptions::new();
|
||||
opts.pathspec(rela_path);
|
||||
|
||||
let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
|
||||
Ok(diff.deltas().len() > 0)
|
||||
}
|
||||
|
||||
fn blob_text_at_path(
|
||||
repo: &git2::Repository,
|
||||
tree: Option<&git2::Tree>,
|
||||
rela_path: &Path,
|
||||
) -> crate::error::Result<String> {
|
||||
let Some(tree) = tree else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let Ok(entry) = tree.get_path(rela_path) else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let blob = entry.to_object(repo)?.peel_to_blob()?;
|
||||
Ok(String::from_utf8(blob.content().to_vec())?)
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
208
crates/yaak-git/src/pull.rs
Normal file
208
crates/yaak-git/src/pull.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use crate::util::{get_current_branch_name, get_default_remote_in_repo};
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum PullResult {
|
||||
Success { message: String },
|
||||
UpToDate,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
Diverged { remote: String, branch: String },
|
||||
UncommittedChanges,
|
||||
}
|
||||
|
||||
fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
|
||||
let repo = open_repo(dir)?;
|
||||
// Scoped to the sync directory: uncommitted changes elsewhere in a
|
||||
// containing monorepo shouldn't block pulling. Git itself still refuses
|
||||
// a merge that would clobber uncommitted files outside the sync dir
|
||||
let mut opts = crate::status::scoped_status_options(&repo, dir);
|
||||
opts.include_untracked(false);
|
||||
let statuses = repo.statuses(Some(&mut opts))?;
|
||||
Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT))
|
||||
}
|
||||
|
||||
pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||
if has_uncommitted_changes(dir)? {
|
||||
return Ok(PullResult::UncommittedChanges);
|
||||
}
|
||||
|
||||
// Extract all git2 data before any await points (git2 types are not Send)
|
||||
let (branch_name, remote_name, remote_url) = {
|
||||
let repo = open_repo(dir)?;
|
||||
let branch_name = get_current_branch_name(&repo)?;
|
||||
let remote = get_default_remote_in_repo(&repo)?;
|
||||
let remote_name =
|
||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
||||
let remote_url =
|
||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
||||
(branch_name, remote_name, remote_url)
|
||||
};
|
||||
|
||||
// Step 1: fetch the specific branch
|
||||
// NOTE: We use fetch + merge instead of `git pull` to avoid conflicts with
|
||||
// global git config (e.g. pull.ff=only) and the background fetch --all.
|
||||
let fetch_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["fetch", &remote_name, &branch_name])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?;
|
||||
|
||||
let fetch_stdout = String::from_utf8_lossy(&fetch_out.stdout);
|
||||
let fetch_stderr = String::from_utf8_lossy(&fetch_out.stderr);
|
||||
let fetch_combined = format!("{fetch_stdout}{fetch_stderr}");
|
||||
|
||||
info!("Fetched status={} {fetch_combined}", fetch_out.status);
|
||||
|
||||
if fetch_combined.to_lowercase().contains("could not read") {
|
||||
return Ok(PullResult::NeedsCredentials { url: remote_url.to_string(), error: None });
|
||||
}
|
||||
|
||||
if fetch_combined.to_lowercase().contains("unable to access") {
|
||||
return Ok(PullResult::NeedsCredentials {
|
||||
url: remote_url.to_string(),
|
||||
error: Some(fetch_combined.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !fetch_out.status.success() {
|
||||
return Err(GenericError(format!("Failed to fetch: {fetch_combined}")));
|
||||
}
|
||||
|
||||
// Step 2: merge the fetched branch
|
||||
let ref_name = format!("{}/{}", remote_name, branch_name);
|
||||
let merge_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["merge", "--ff-only", &ref_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
|
||||
|
||||
let merge_stdout = String::from_utf8_lossy(&merge_out.stdout);
|
||||
let merge_stderr = String::from_utf8_lossy(&merge_out.stderr);
|
||||
let merge_combined = format!("{merge_stdout}{merge_stderr}");
|
||||
|
||||
info!("Merged status={} {merge_combined}", merge_out.status);
|
||||
|
||||
if !merge_out.status.success() {
|
||||
let merge_lower = merge_combined.to_lowercase();
|
||||
if merge_lower.contains("cannot fast-forward")
|
||||
|| merge_lower.contains("not possible to fast-forward")
|
||||
|| merge_lower.contains("diverged")
|
||||
{
|
||||
return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name });
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge: {merge_combined}")));
|
||||
}
|
||||
|
||||
if merge_combined.to_lowercase().contains("up to date") {
|
||||
return Ok(PullResult::UpToDate);
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Pulled from {}/{}", remote_name, branch_name) })
|
||||
}
|
||||
|
||||
pub async fn git_pull_force_reset(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
// Step 1: fetch the remote
|
||||
let fetch_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["fetch", remote])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?;
|
||||
|
||||
if !fetch_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&fetch_out.stderr);
|
||||
return Err(GenericError(format!("Failed to fetch: {stderr}")));
|
||||
}
|
||||
|
||||
// Step 2: reset --hard to remote/branch
|
||||
let ref_name = format!("{}/{}", remote, branch);
|
||||
let reset_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["reset", "--hard", &ref_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
|
||||
|
||||
if !reset_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&reset_out.stderr);
|
||||
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Reset to {}/{}", remote, branch) })
|
||||
}
|
||||
|
||||
pub async fn git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["pull", "--no-rebase", remote, branch])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git pull --no-rebase: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
info!("Pull merge status={} {combined}", out.status);
|
||||
|
||||
if !out.status.success() {
|
||||
if combined.to_lowercase().contains("conflict") {
|
||||
return Err(GenericError(
|
||||
"Merge conflicts detected. Please resolve them manually.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge pull: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Merged from {}/{}", remote, branch) })
|
||||
}
|
||||
|
||||
// pub(crate) fn git_pull_old(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::Success {
|
||||
// message: "Hello".to_string(),
|
||||
// // received_bytes: stats.received_bytes(),
|
||||
// // received_objects: stats.received_objects(),
|
||||
// })
|
||||
// }
|
||||
89
crates/yaak-git/src/push.rs
Normal file
89
crates/yaak-git/src/push.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use crate::util::{get_current_branch_name, get_default_remote_for_push_in_repo};
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum PushResult {
|
||||
Success { message: String },
|
||||
UpToDate,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
}
|
||||
|
||||
pub async fn git_push(dir: &Path) -> Result<PushResult> {
|
||||
// Extract all git2 data before any await points (git2 types are not Send)
|
||||
let (branch_name, remote_name, remote_url) = {
|
||||
let repo = open_repo(dir)?;
|
||||
let branch_name = get_current_branch_name(&repo)?;
|
||||
let remote = get_default_remote_for_push_in_repo(&repo)?;
|
||||
let remote_name =
|
||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
||||
let remote_url =
|
||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
||||
(branch_name, remote_name, remote_url)
|
||||
};
|
||||
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["push", &remote_name, &branch_name])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = stdout + stderr;
|
||||
let combined_lower = combined.to_lowercase();
|
||||
|
||||
info!("Pushed to repo status={} {combined}", out.status);
|
||||
|
||||
// Helper to check if this is a credentials error
|
||||
let is_credentials_error = || {
|
||||
combined_lower.contains("could not read")
|
||||
|| combined_lower.contains("unable to access")
|
||||
|| combined_lower.contains("authentication failed")
|
||||
};
|
||||
|
||||
// Check for explicit rejection indicators first (e.g., protected branch rejections)
|
||||
// These can occur even if some git servers don't properly set exit codes
|
||||
if combined_lower.contains("rejected") || combined_lower.contains("failed to push") {
|
||||
if is_credentials_error() {
|
||||
return Ok(PushResult::NeedsCredentials {
|
||||
url: remote_url.to_string(),
|
||||
error: Some(combined.to_string()),
|
||||
});
|
||||
}
|
||||
return Err(GenericError(format!("Failed to push: {combined}")));
|
||||
}
|
||||
|
||||
// Check exit status for any other failures
|
||||
if !out.status.success() {
|
||||
if combined_lower.contains("could not read") {
|
||||
return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: None });
|
||||
}
|
||||
if combined_lower.contains("unable to access")
|
||||
|| combined_lower.contains("authentication failed")
|
||||
{
|
||||
return Ok(PushResult::NeedsCredentials {
|
||||
url: remote_url.to_string(),
|
||||
error: Some(combined.to_string()),
|
||||
});
|
||||
}
|
||||
return Err(GenericError(format!("Failed to push: {combined}")));
|
||||
}
|
||||
|
||||
// Success cases (exit code 0 and no rejection indicators)
|
||||
if combined_lower.contains("up-to-date") {
|
||||
return Ok(PushResult::UpToDate);
|
||||
}
|
||||
|
||||
Ok(PushResult::Success { message: format!("Pushed to {}/{}", remote_name, branch_name) })
|
||||
}
|
||||
47
crates/yaak-git/src/remotes.rs
Normal file
47
crates/yaak-git/src/remotes.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitRemote {
|
||||
name: String,
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
pub fn git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {
|
||||
let repo = open_repo(dir)?;
|
||||
let mut remotes = Vec::new();
|
||||
|
||||
for remote in repo.remotes()?.into_iter() {
|
||||
let name = match remote {
|
||||
None => continue,
|
||||
Some(name) => name,
|
||||
};
|
||||
let r = match repo.find_remote(name) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Failed to get remote {name}: {e:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
remotes.push(GitRemote { name: name.to_string(), url: r.url().map(|u| u.to_string()) });
|
||||
}
|
||||
|
||||
Ok(remotes)
|
||||
}
|
||||
|
||||
pub fn git_add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {
|
||||
let repo = open_repo(dir)?;
|
||||
repo.remote(name, url)?;
|
||||
Ok(GitRemote { name: name.to_string(), url: Some(url.to_string()) })
|
||||
}
|
||||
|
||||
pub fn git_rm_remote(dir: &Path, name: &str) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
repo.remote_delete(name)?;
|
||||
Ok(())
|
||||
}
|
||||
38
crates/yaak-git/src/repository.rs
Normal file
38
crates/yaak-git/src/repository.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::error::Error::{GitRepoNotFound, GitUnknown};
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitRepositoryPaths {
|
||||
pub workdir: PathBuf,
|
||||
pub gitdir: PathBuf,
|
||||
pub commondir: PathBuf,
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_repository_paths(dir: &Path) -> Result<GitRepositoryPaths> {
|
||||
let repo = open_repo(dir)?;
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
|
||||
.to_path_buf();
|
||||
Ok(GitRepositoryPaths {
|
||||
workdir,
|
||||
gitdir: repo.path().to_path_buf(),
|
||||
// Same as gitdir except for linked worktrees, where shared refs and
|
||||
// packed-refs live in the main repository's git directory
|
||||
commondir: repo.commondir().to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result<bool> {
|
||||
let repo = open_repo(dir)?;
|
||||
Ok(repo.status_should_ignore(rela_path)?)
|
||||
}
|
||||
20
crates/yaak-git/src/reset.rs
Normal file
20
crates/yaak-git/src/reset.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn git_reset_changes(dir: &Path) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["reset", "--hard", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
76
crates/yaak-git/src/restore.rs
Normal file
76
crates/yaak-git/src/restore.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use log::info;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path};
|
||||
|
||||
pub fn git_restore(dir: &Path, rela_path: &Path) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
validate_relative_path(rela_path)?;
|
||||
|
||||
let status = repo.status_file(rela_path).ok();
|
||||
let is_untracked = status
|
||||
.is_some_and(|s| s.contains(git2::Status::WT_NEW) || s.contains(git2::Status::INDEX_NEW));
|
||||
|
||||
info!("Restoring file {rela_path:?} in {dir:?}");
|
||||
if is_untracked {
|
||||
let mut index = repo.index()?;
|
||||
let _ = index.remove_path(rela_path);
|
||||
index.write()?;
|
||||
|
||||
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let head = repo.head()?;
|
||||
let commit = head.peel_to_commit()?;
|
||||
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
|
||||
|
||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||
checkout.force().path(rela_path);
|
||||
repo.checkout_head(Some(&mut checkout))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_restore_file_from_commit(dir: &Path, commit_oid: &str, rela_path: &Path) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
validate_relative_path(rela_path)?;
|
||||
|
||||
let oid = git2::Oid::from_str(commit_oid)?;
|
||||
let commit = repo.find_commit(oid)?;
|
||||
let tree = commit.tree()?;
|
||||
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
||||
|
||||
info!("Restoring file {rela_path:?} from commit {commit_oid} in {dir:?}");
|
||||
if tree.get_path(rela_path).is_err() {
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||
checkout.force().path(rela_path);
|
||||
repo.checkout_tree(commit.as_object(), Some(&mut checkout))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_relative_path(path: &Path) -> Result<()> {
|
||||
let is_safe = !path.as_os_str().is_empty()
|
||||
&& !path.is_absolute()
|
||||
&& path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||
if is_safe {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::error::Error::GenericError(format!("Invalid restore path {}", path.display())))
|
||||
}
|
||||
}
|
||||
375
crates/yaak-git/src/status.rs
Normal file
375
crates/yaak-git/src/status.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
use crate::repository::open_repo;
|
||||
use crate::util::{local_branch_names, remote_branch_names};
|
||||
use log::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,
|
||||
/// The status directory relative to the repo root ("" when it IS the root).
|
||||
/// Useful for displaying entry paths relative to the sync directory
|
||||
pub rela_dir: String,
|
||||
pub head_ref: Option<String>,
|
||||
pub head_ref_shorthand: Option<String>,
|
||||
pub entries: Vec<GitStatusEntry>,
|
||||
pub origins: Vec<String>,
|
||||
pub local_branches: Vec<String>,
|
||||
pub remote_branches: Vec<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitBranchInfo {
|
||||
pub path: String,
|
||||
pub head_ref: Option<String>,
|
||||
pub head_ref_shorthand: Option<String>,
|
||||
pub origins: Vec<String>,
|
||||
pub local_branches: Vec<String>,
|
||||
pub remote_branches: Vec<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
}
|
||||
|
||||
#[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, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitWorktreeStatus {
|
||||
pub entries: Vec<GitWorktreeStatusEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitWorktreeStatusEntry {
|
||||
pub rela_path: String,
|
||||
pub model_id: Option<String>,
|
||||
pub status: GitStatus,
|
||||
pub staged: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum GitStatus {
|
||||
Untracked,
|
||||
Conflict,
|
||||
Current,
|
||||
Modified,
|
||||
Removed,
|
||||
Renamed,
|
||||
TypeChange,
|
||||
}
|
||||
|
||||
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
|
||||
let repo = open_repo(dir)?;
|
||||
let mut opts = scoped_status_options(&repo, dir);
|
||||
opts.include_unmodified(false);
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
||||
let Some(rela_path) = entry.path() else {
|
||||
continue;
|
||||
};
|
||||
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
entries.push(GitWorktreeStatusEntry {
|
||||
rela_path: rela_path.to_string(),
|
||||
model_id: model_id_from_rela_path(Path::new(rela_path)),
|
||||
status,
|
||||
staged,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(GitWorktreeStatus { entries })
|
||||
}
|
||||
|
||||
pub fn git_branch_info(dir: &Path) -> crate::error::Result<GitBranchInfo> {
|
||||
let repo = open_repo(dir)?;
|
||||
git_branch_info_for_repo(&repo, dir)
|
||||
}
|
||||
|
||||
pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
let repo = open_repo(dir)?;
|
||||
let branch_info = git_branch_info_for_repo(&repo, dir)?;
|
||||
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
|
||||
|
||||
let mut opts = scoped_status_options(&repo, dir);
|
||||
opts.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 Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// 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)?;
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
Ok(GitStatusSummary {
|
||||
entries,
|
||||
rela_dir: repo_relative_dir(&repo, dir).unwrap_or_default(),
|
||||
path: branch_info.path,
|
||||
head_ref: branch_info.head_ref,
|
||||
head_ref_shorthand: branch_info.head_ref_shorthand,
|
||||
origins: branch_info.origins,
|
||||
local_branches: branch_info.local_branches,
|
||||
remote_branches: branch_info.remote_branches,
|
||||
ahead: branch_info.ahead,
|
||||
behind: branch_info.behind,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_branch_info_for_repo(
|
||||
repo: &git2::Repository,
|
||||
dir: &Path,
|
||||
) -> crate::error::Result<GitBranchInfo> {
|
||||
let (head_ref, head_ref_shorthand) = git_head_refs(repo);
|
||||
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
||||
let local_branches = local_branch_names(repo)?;
|
||||
let remote_branches = remote_branch_names(repo)?;
|
||||
|
||||
// Compute ahead/behind relative to remote tracking branch
|
||||
let (ahead, behind) = (|| -> Option<(usize, usize)> {
|
||||
let head = repo.head().ok()?;
|
||||
let local_oid = head.target()?;
|
||||
let branch_name = head.shorthand()?;
|
||||
let upstream_ref =
|
||||
repo.find_branch(&format!("origin/{branch_name}"), git2::BranchType::Remote).ok()?;
|
||||
let upstream_oid = upstream_ref.get().target()?;
|
||||
repo.graph_ahead_behind(local_oid, upstream_oid).ok()
|
||||
})()
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
Ok(GitBranchInfo {
|
||||
path: dir.to_string_lossy().to_string(),
|
||||
head_ref,
|
||||
head_ref_shorthand,
|
||||
origins,
|
||||
local_branches,
|
||||
remote_branches,
|
||||
ahead: ahead as u32,
|
||||
behind: behind as u32,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_head_refs(repo: &git2::Repository) -> (Option<String>, Option<String>) {
|
||||
match repo.head() {
|
||||
Ok(head) => {
|
||||
let head_ref = head.name().map(|s| s.to_string());
|
||||
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
||||
(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());
|
||||
(head_ref, head_ref_shorthand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> {
|
||||
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::Untracked,
|
||||
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:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
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::Untracked,
|
||||
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:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let status =
|
||||
if index_status == GitStatus::Current { worktree_status } else { index_status.clone() };
|
||||
let staged = index_status != GitStatus::Current;
|
||||
|
||||
Some((status, staged))
|
||||
}
|
||||
|
||||
/// Construct StatusOptions for a walk scoped to `dir`. Yaak only cares about
|
||||
/// the sync directory, and a full walk is expensive when the containing repo
|
||||
/// is large (e.g. a sync dir inside a monorepo); scoping is a no-op when
|
||||
/// `dir` is the repo root. Always build status walks through this so a new
|
||||
/// call site can't forget the scoping.
|
||||
pub(crate) fn scoped_status_options(repo: &git2::Repository, dir: &Path) -> git2::StatusOptions {
|
||||
let mut opts = git2::StatusOptions::new();
|
||||
opts.include_ignored(false).include_untracked(true).recurse_untracked_dirs(true);
|
||||
if let Some(rela) = repo_relative_dir(repo, dir) {
|
||||
opts.pathspec(rela);
|
||||
// Match the path literally (exact or directory prefix) instead of as
|
||||
// a glob — directory names can contain pattern characters like [ or *
|
||||
opts.disable_pathspec_match(true);
|
||||
}
|
||||
opts
|
||||
}
|
||||
|
||||
/// The path of `dir` relative to the repo root as a forward-slash string
|
||||
/// (Git pathspecs use forward slashes even on Windows), or None when `dir`
|
||||
/// is the root itself (or outside the repo). Both sides are canonicalized so
|
||||
/// symlinked paths compare consistently.
|
||||
pub(crate) fn repo_relative_dir(repo: &git2::Repository, dir: &Path) -> Option<String> {
|
||||
let workdir = repo.workdir()?;
|
||||
let workdir = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf());
|
||||
let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
|
||||
let rela = canonical_dir.strip_prefix(&workdir).ok()?;
|
||||
if rela.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<String> =
|
||||
rela.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
fn model_id_from_rela_path(path: &Path) -> Option<String> {
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if ext != "yaml" && ext != "yml" && ext != "json" {
|
||||
return None;
|
||||
}
|
||||
|
||||
path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_worktree_status_scoped_to_subdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
|
||||
let sync_dir = tmp.path().join("sync");
|
||||
std::fs::create_dir(&sync_dir).unwrap();
|
||||
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
|
||||
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
|
||||
|
||||
// Status on a subdirectory only reports that subdirectory
|
||||
let status = git_worktree_status(&sync_dir).unwrap();
|
||||
let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
|
||||
assert_eq!(paths, vec!["sync/yaak.req_1.yaml"]);
|
||||
assert_eq!(status.entries[0].model_id.as_deref(), Some("req_1"));
|
||||
|
||||
// Status on the repo root reports everything
|
||||
let status = git_worktree_status(tmp.path()).unwrap();
|
||||
assert_eq!(status.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worktree_status_scoped_literal_dir_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
|
||||
// A directory name that is also a valid glob pattern ([1] matches "1")
|
||||
let sync_dir = tmp.path().join("sync[1]");
|
||||
std::fs::create_dir(&sync_dir).unwrap();
|
||||
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
|
||||
std::fs::create_dir(tmp.path().join("sync1")).unwrap();
|
||||
std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "glob match").unwrap();
|
||||
|
||||
let status = git_worktree_status(&sync_dir).unwrap();
|
||||
let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
|
||||
assert_eq!(paths, vec!["sync[1]/yaak.req_1.yaml"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_scoped_to_subdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
|
||||
let sync_dir = tmp.path().join("sync");
|
||||
std::fs::create_dir(&sync_dir).unwrap();
|
||||
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
|
||||
std::fs::write(sync_dir.join("README.md"), "external, but in sync dir").unwrap();
|
||||
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
|
||||
|
||||
// The commit dialog's status only reports the sync directory
|
||||
let status = git_status(&sync_dir).unwrap();
|
||||
assert_eq!(status.rela_dir, "sync");
|
||||
let mut paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec!["sync/README.md", "sync/yaak.req_1.yaml"]);
|
||||
|
||||
// Status on the repo root reports everything
|
||||
let status = git_status(tmp.path()).unwrap();
|
||||
assert_eq!(status.rela_dir, "");
|
||||
assert_eq!(status.entries.len(), 3);
|
||||
}
|
||||
}
|
||||
27
crates/yaak-git/src/unstage.rs
Normal file
27
crates/yaak-git/src/unstage.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::repository::open_repo;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn git_unstage(dir: &Path, rela_path: &Path) -> crate::error::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(())
|
||||
}
|
||||
121
crates/yaak-git/src/util.rs
Normal file
121
crates/yaak-git/src/util.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use crate::error::Error::{GenericError, NoDefaultRemoteFound};
|
||||
use crate::error::Result;
|
||||
use git2::{Branch, BranchType, Remote, Repository};
|
||||
|
||||
const DEFAULT_REMOTE_NAME: &str = "origin";
|
||||
|
||||
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 get_current_branch_name(repo: &Repository) -> Result<String> {
|
||||
Ok(get_current_branch(&repo)?
|
||||
.ok_or(GenericError("Failed to get current branch".to_string()))?
|
||||
.name()?
|
||||
.ok_or(GenericError("Failed to get current branch name".to_string()))?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
||||
let mut branches = Vec::new();
|
||||
for branch in repo.branches(Some(BranchType::Local))? {
|
||||
let (branch, _) = branch?;
|
||||
let name = branch.name_bytes()?;
|
||||
let name = bytes_to_string(name)?;
|
||||
branches.push(name);
|
||||
}
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
||||
let mut branches = Vec::new();
|
||||
for branch in repo.branches(Some(BranchType::Remote))? {
|
||||
let (branch, _) = branch?;
|
||||
let name = branch.name_bytes()?;
|
||||
let name = bytes_to_string(name)?;
|
||||
if name.ends_with("/HEAD") {
|
||||
continue;
|
||||
}
|
||||
branches.push(name);
|
||||
}
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
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<Remote<'_>> {
|
||||
let name = get_default_remote_name_for_push_in_repo(repo)?;
|
||||
let remote = repo.find_remote(&name)?;
|
||||
Ok(remote)
|
||||
}
|
||||
|
||||
pub(crate) fn get_default_remote_name_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_name_in_repo(repo)
|
||||
}
|
||||
|
||||
pub(crate) fn get_default_remote_in_repo(repo: &'_ Repository) -> Result<Remote<'_>> {
|
||||
let name = get_default_remote_name_in_repo(repo)?;
|
||||
let remote = repo.find_remote(&name)?;
|
||||
Ok(remote)
|
||||
}
|
||||
|
||||
pub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result<String> {
|
||||
let remotes = repo.remotes()?;
|
||||
|
||||
if remotes.is_empty() {
|
||||
return Err(NoDefaultRemoteFound);
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user