snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View 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
View 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
View 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
View 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 });
}

View File

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

View 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(())
}

View 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)
}

View 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(())
}

View 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)
}

View 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);
}
}

View 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(())
}

View 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>;

View 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(())
}

View 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(())
}

View 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
View 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
View 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(),
// })
// }

View 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) })
}

View 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(())
}

View 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)?)
}

View 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(())
}

View 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())))
}
}

View 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);
}
}

View 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
View 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)
}