The credential expiry feature has been removed from both the Rust backend and the frontend. Stored credentials now include only username and password. - Remove expiresAt field from StoredCredential - Simplify API by removing expiry param from credSave - Drop expiry UI and expiry checks across the app
8003 lines
260 KiB
Rust
8003 lines
260 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
env,
|
|
ffi::{OsStr, OsString},
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
process::{Command, Output, Stdio},
|
|
sync::{
|
|
Arc, Mutex,
|
|
atomic::{AtomicU64, Ordering},
|
|
},
|
|
thread,
|
|
time::Duration,
|
|
};
|
|
|
|
#[cfg(windows)]
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
#[cfg(windows)]
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum FileStatusKind {
|
|
Modified,
|
|
Added,
|
|
Deleted,
|
|
Renamed,
|
|
Untracked,
|
|
Conflicted,
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitFileStatus {
|
|
pub path: String,
|
|
pub old_path: Option<String>,
|
|
pub staged: Option<FileStatusKind>,
|
|
pub unstaged: Option<FileStatusKind>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitStatus {
|
|
pub repo_path: String,
|
|
pub current_branch: Option<String>,
|
|
pub upstream: Option<String>,
|
|
pub ahead: u32,
|
|
pub behind: u32,
|
|
pub files: Vec<GitFileStatus>,
|
|
pub clean: bool,
|
|
pub rebase_in_progress: bool,
|
|
pub cherry_pick_in_progress: bool,
|
|
pub merge_in_progress: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitRemote {
|
|
pub name: String,
|
|
pub fetch_url: String,
|
|
pub push_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitBranch {
|
|
pub name: String,
|
|
pub current: bool,
|
|
pub remote: bool,
|
|
pub upstream: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitWorktree {
|
|
pub path: String,
|
|
pub head: Option<String>,
|
|
pub short_head: Option<String>,
|
|
pub branch: Option<String>,
|
|
pub bare: bool,
|
|
pub detached: bool,
|
|
pub locked: bool,
|
|
pub lock_reason: Option<String>,
|
|
pub prunable: bool,
|
|
pub prune_reason: Option<String>,
|
|
pub missing: bool,
|
|
pub is_main: bool,
|
|
pub is_current: bool,
|
|
pub clean: bool,
|
|
pub changed_files: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitTag {
|
|
pub name: String,
|
|
pub hash: String,
|
|
pub short_hash: String,
|
|
pub message: Option<String>,
|
|
pub date: String,
|
|
pub annotated: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitStash {
|
|
pub selector: String,
|
|
pub index: u32,
|
|
pub hash: String,
|
|
pub branch: Option<String>,
|
|
pub message: String,
|
|
pub date: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitCommit {
|
|
pub hash: String,
|
|
pub short_hash: String,
|
|
pub summary: String,
|
|
pub author_name: String,
|
|
pub author_email: String,
|
|
pub date: String,
|
|
pub refs: Vec<String>,
|
|
pub parents: Vec<String>,
|
|
pub files: Vec<GitCommitFile>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitCommitFile {
|
|
pub path: String,
|
|
pub old_path: Option<String>,
|
|
pub status: FileStatusKind,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitDiffFile {
|
|
pub path: String,
|
|
pub old_path: Option<String>,
|
|
pub status: FileStatusKind,
|
|
pub additions: u32,
|
|
pub deletions: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitCommitComparison {
|
|
pub from_hash: String,
|
|
pub from_short: String,
|
|
pub to_hash: String,
|
|
pub to_short: String,
|
|
pub files: Vec<GitDiffFile>,
|
|
pub patch: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitBlameLine {
|
|
pub line_number: u32,
|
|
pub content: String,
|
|
pub commit_hash: String,
|
|
pub short_hash: String,
|
|
pub author_name: String,
|
|
pub author_email: String,
|
|
pub author_time: i64,
|
|
pub summary: String,
|
|
pub is_uncommitted: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitBlameResult {
|
|
pub path: String,
|
|
pub lines: Vec<GitBlameLine>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct ConflictFile {
|
|
pub path: String,
|
|
pub content: String,
|
|
pub ours: Option<String>,
|
|
pub theirs: Option<String>,
|
|
pub base: Option<String>,
|
|
pub binary: bool,
|
|
pub ours_size: Option<u64>,
|
|
pub theirs_size: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitRepositoryFile {
|
|
pub path: String,
|
|
pub tracked: bool,
|
|
pub status: Option<FileStatusKind>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct GitSearchHit {
|
|
pub commit_hash: String,
|
|
pub short_hash: String,
|
|
pub summary: String,
|
|
pub author_name: String,
|
|
pub author_email: String,
|
|
pub date: String,
|
|
pub file: String,
|
|
pub old_file: Option<String>,
|
|
pub line_number: Option<u32>,
|
|
pub line: String,
|
|
pub matches_added: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct RebaseCommit {
|
|
pub hash: String,
|
|
pub short_hash: String,
|
|
pub summary: String,
|
|
pub author_name: String,
|
|
pub date: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum RebaseAction {
|
|
Pick,
|
|
Reword,
|
|
Squash,
|
|
Fixup,
|
|
Drop,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
pub struct RebasePlanItem {
|
|
pub hash: String,
|
|
pub action: RebaseAction,
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct ReflogEntry {
|
|
pub hash: String,
|
|
pub short_hash: String,
|
|
pub selector: String,
|
|
pub action: String,
|
|
pub author_name: String,
|
|
pub date: String,
|
|
}
|
|
|
|
const SEQUENCE_EDITOR_PLAN_ENV: &str = "GITTY_SEQUENCE_EDITOR_PLAN";
|
|
const COMMIT_EDITOR_QUEUE_ENV: &str = "GITTY_COMMIT_EDITOR_QUEUE";
|
|
const REBASE_TODO_FILE: &str = "gitty-interactive-rebase-todo";
|
|
const REWORD_QUEUE_FILE: &str = "gitty-interactive-rebase-messages";
|
|
const SEQUENCE_HELPER_STEM: &str = ".gitty-sequence-editor";
|
|
const COMMIT_HELPER_STEM: &str = ".gitty-commit-editor";
|
|
|
|
pub fn run_sequence_editor_if_requested() -> Option<Result<(), String>> {
|
|
let executable = env::current_exe().ok()?;
|
|
let executable_name = executable.file_name()?.to_string_lossy();
|
|
let target = env::args_os()
|
|
.nth(1)
|
|
.ok_or_else(|| "Git did not provide a sequence-editor target path.".to_string());
|
|
if executable_name.contains("gitty-sequence-editor") {
|
|
let plan = env::var_os(SEQUENCE_EDITOR_PLAN_ENV)?;
|
|
return Some(target.and_then(|target| {
|
|
fs::copy(PathBuf::from(plan), PathBuf::from(target))
|
|
.map(|_| ())
|
|
.map_err(|err| format!("Could not write the interactive rebase plan: {err}"))
|
|
}));
|
|
}
|
|
if executable_name.contains("gitty-commit-editor") {
|
|
let queue = env::var_os(COMMIT_EDITOR_QUEUE_ENV)?;
|
|
return Some(
|
|
target.and_then(|target| apply_reword_message(Path::new(&queue), Path::new(&target))),
|
|
);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn apply_reword_message(queue_path: &Path, message_path: &Path) -> Result<(), String> {
|
|
let queue = fs::read_to_string(queue_path)
|
|
.map_err(|err| format!("Could not read the reword queue: {err}"))?;
|
|
if queue.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let current = fs::read_to_string(message_path)
|
|
.map_err(|err| format!("Could not read the commit message: {err}"))?;
|
|
let current_subject = current
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|line| !line.is_empty() && !line.starts_with('#'))
|
|
.unwrap_or("");
|
|
|
|
let mut remaining = Vec::new();
|
|
let mut replacement = None;
|
|
for record in queue.split('\x1e').filter(|record| !record.is_empty()) {
|
|
let Some((old, new)) = record.split_once('\x1f') else {
|
|
return Err("The reword queue is malformed.".to_string());
|
|
};
|
|
if replacement.is_none() && old == current_subject {
|
|
replacement = Some(new.to_string());
|
|
} else {
|
|
remaining.push(record);
|
|
}
|
|
}
|
|
|
|
if let Some(message) = replacement {
|
|
fs::write(message_path, format!("{message}\n"))
|
|
.map_err(|err| format!("Could not update the commit message: {err}"))?;
|
|
let mut next_queue = remaining.join("\x1e");
|
|
if !next_queue.is_empty() {
|
|
next_queue.push('\x1e');
|
|
}
|
|
fs::write(queue_path, next_queue)
|
|
.map_err(|err| format!("Could not update the reword queue: {err}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
struct BranchInfo {
|
|
current_branch: Option<String>,
|
|
upstream: Option<String>,
|
|
ahead: u32,
|
|
behind: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum CheckoutPlan {
|
|
Local(String),
|
|
TrackRemote { local: String, remote: String },
|
|
Raw(String),
|
|
}
|
|
|
|
const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000";
|
|
const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
const SEARCH_CANCELLED_MESSAGE: &str = "Search was cancelled.";
|
|
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
fn git_command() -> Command {
|
|
let mut command = Command::new("git");
|
|
// Force English output regardless of the system locale, so is_auth_error()
|
|
// and other message heuristics keep working (e.g. German git prints
|
|
// "Authentifizierung fehlgeschlagen" instead of "Authentication failed").
|
|
command.env("LC_ALL", "C");
|
|
#[cfg(windows)]
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
command
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct SearchCancellationState {
|
|
cancelled: Arc<Mutex<BTreeSet<String>>>,
|
|
}
|
|
|
|
impl SearchCancellationState {
|
|
fn cancel(&self, search_id: &str) -> Result<(), String> {
|
|
self.cancelled
|
|
.lock()
|
|
.map_err(|_| "Search cancellation status is unavailable.".to_string())?
|
|
.insert(search_id.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
fn clear(&self, search_id: &str) -> Result<(), String> {
|
|
self.cancelled
|
|
.lock()
|
|
.map_err(|_| "Search cancellation status is unavailable.".to_string())?
|
|
.remove(search_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn is_cancelled(&self, search_id: &str) -> Result<bool, String> {
|
|
Ok(self
|
|
.cancelled
|
|
.lock()
|
|
.map_err(|_| "Search cancellation status is unavailable.".to_string())?
|
|
.contains(search_id))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SearchCancellation {
|
|
state: SearchCancellationState,
|
|
search_id: String,
|
|
}
|
|
|
|
fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(), String> {
|
|
if let Some(cancellation) = cancellation {
|
|
if cancellation.state.is_cancelled(&cancellation.search_id)? {
|
|
return Err(SEARCH_CANCELLED_MESSAGE.to_string());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<GitStatus, String> {
|
|
let path = PathBuf::from(path.trim());
|
|
if path.as_os_str().is_empty() {
|
|
return Err("Repository path must not be empty.".to_string());
|
|
}
|
|
fs::create_dir_all(&path)
|
|
.map_err(|err| format!("Could not create repository folder: {err}"))?;
|
|
let branch = initial_branch.unwrap_or_else(|| "main".to_string());
|
|
let branch = branch.trim();
|
|
if branch.is_empty() {
|
|
return Err("Initial branch must not be empty.".to_string());
|
|
}
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&path)
|
|
.args(["init", "-b", branch])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"Could not initialize repository: {}",
|
|
command_output_details(&output)
|
|
));
|
|
}
|
|
status_for_repo(&path)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
|
let repo = resolve_repo(&path)?;
|
|
open_path_in_file_manager(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
let file_path = resolve_repo_child_path(&repo, &file)?;
|
|
|
|
if !file_path.exists() {
|
|
return Err(format!("File '{file}' does not exist in the working tree."));
|
|
}
|
|
if !file_path.is_file() {
|
|
return Err(format!("'{file}' is not a file."));
|
|
}
|
|
|
|
reveal_path_in_file_manager(&file_path)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct RepositoryBundle {
|
|
pub status: GitStatus,
|
|
pub branches: Vec<GitBranch>,
|
|
pub tags: Vec<GitTag>,
|
|
pub stashes: Vec<GitStash>,
|
|
pub commits: Vec<GitCommit>,
|
|
pub files: Vec<GitRepositoryFile>,
|
|
}
|
|
|
|
async fn run_git_task<T, F>(context: &'static str, task: F) -> Result<T, String>
|
|
where
|
|
T: Send + 'static,
|
|
F: FnOnce() -> Result<T, String> + Send + 'static,
|
|
{
|
|
tauri::async_runtime::spawn_blocking(task)
|
|
.await
|
|
.map_err(|error| format!("{context}: {error}"))?
|
|
}
|
|
|
|
fn join_git_worker<T>(name: &str, result: thread::Result<Result<T, String>>) -> Result<T, String> {
|
|
result.map_err(|_| format!("The {name} Git worker stopped unexpectedly."))?
|
|
}
|
|
|
|
fn repository_bundle_for_repo(
|
|
repo: &Path,
|
|
commit_limit: Option<u32>,
|
|
) -> Result<RepositoryBundle, String> {
|
|
// Status is needed by the file tree. Once it is available, all remaining
|
|
// reads are independent and can run concurrently. Each worker only starts
|
|
// read-only Git processes, so this is safe while cutting the former
|
|
// branches -> tags -> stashes -> commits -> files waterfall down to the
|
|
// duration of its slowest member.
|
|
let status = status_for_repo(repo)?;
|
|
let (branches, tags, stashes, commits, files) = thread::scope(|scope| -> Result<_, String> {
|
|
let branches = scope.spawn(|| branches_for_repo(repo));
|
|
let tags = scope.spawn(|| tags_for_repo(repo));
|
|
let stashes = scope.spawn(|| stashes_for_repo(repo));
|
|
let commits = scope.spawn(|| commits_for_repo(repo, commit_limit));
|
|
let files = scope.spawn(|| repository_files_with_status(repo, &status));
|
|
|
|
Ok((
|
|
join_git_worker("branch", branches.join())?,
|
|
join_git_worker("tag", tags.join())?,
|
|
join_git_worker("stash", stashes.join())?,
|
|
join_git_worker("history", commits.join())?,
|
|
join_git_worker("file tree", files.join())?,
|
|
))
|
|
})?;
|
|
|
|
Ok(RepositoryBundle {
|
|
status,
|
|
branches,
|
|
tags,
|
|
stashes,
|
|
commits,
|
|
files,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn clone_repository(
|
|
remote_url: String,
|
|
parent_path: String,
|
|
directory_name: Option<String>,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
commit_limit: Option<u32>,
|
|
) -> Result<RepositoryBundle, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
clone_repository_core(
|
|
&remote_url,
|
|
&parent_path,
|
|
directory_name.as_deref(),
|
|
username.as_deref(),
|
|
password.as_deref(),
|
|
commit_limit,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not clone repository: {err}"))?
|
|
}
|
|
|
|
/// Opens a repository and gathers everything the UI needs in a single call.
|
|
///
|
|
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
|
|
/// the repo and its status only once, instead of the previous four separate
|
|
/// commands that each re-ran `git rev-parse` and `git status`.
|
|
#[tauri::command]
|
|
pub async fn open_repository_bundle(
|
|
path: String,
|
|
commit_limit: Option<u32>,
|
|
) -> Result<RepositoryBundle, String> {
|
|
run_git_task("Could not load repository", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
repository_bundle_for_repo(&repo, commit_limit)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_status(path: String) -> Result<GitStatus, String> {
|
|
run_git_task("Could not refresh repository status", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
|
run_git_task("Could not load branches", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
branches_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
|
|
log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}");
|
|
let repo = resolve_repo(&path)?;
|
|
let names = run_git(&repo, ["remote"])?;
|
|
Ok(String::from_utf8_lossy(&names)
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let name = line.trim();
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
Some(GitRemote {
|
|
name: name.to_string(),
|
|
fetch_url: remote_url_for(&repo, name).unwrap_or_default(),
|
|
push_url: remote_push_url_for(&repo, name).unwrap_or_default(),
|
|
})
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let name = validate_remote_name(&repo, &name, false)?;
|
|
let url = validate_remote_url(&url)?;
|
|
run_git(&repo, ["remote", "add", name.as_str(), url.as_str()])?;
|
|
list_remotes(path)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let name = validate_remote_name(&repo, &name, true)?;
|
|
let url = validate_remote_url(&url)?;
|
|
run_git(&repo, ["remote", "set-url", name.as_str(), url.as_str()])?;
|
|
list_remotes(path)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, String> {
|
|
log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}");
|
|
let result = (|| {
|
|
let repo = resolve_repo(&path)?;
|
|
log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display());
|
|
let name = validate_remote_name(&repo, &name, true)?;
|
|
log::info!(target: "gitty::remote", "remove_remote validated remote: {name}");
|
|
run_git(&repo, ["remote", "remove", name.as_str()])?;
|
|
log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}");
|
|
list_remotes(path)
|
|
})();
|
|
match &result {
|
|
Ok(remotes) => {
|
|
log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::<Vec<_>>())
|
|
}
|
|
Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"),
|
|
}
|
|
result
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn set_branch_upstream(
|
|
path: String,
|
|
branch: String,
|
|
upstream: Option<String>,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let branch = validate_existing_local_branch_name(&repo, &branch)?;
|
|
match upstream
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
{
|
|
Some(upstream) => {
|
|
if !ref_exists(&repo, &format!("refs/remotes/{upstream}"))? {
|
|
return Err(format!("Remote branch '{upstream}' was not found."));
|
|
}
|
|
run_git(
|
|
&repo,
|
|
[
|
|
"branch",
|
|
"--set-upstream-to",
|
|
upstream.as_str(),
|
|
branch.as_str(),
|
|
],
|
|
)?;
|
|
}
|
|
None => {
|
|
run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?;
|
|
}
|
|
}
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn delete_remote_branch(
|
|
path: String,
|
|
remote: String,
|
|
branch: String,
|
|
) -> Result<GitStatus, String> {
|
|
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}");
|
|
let result = (|| {
|
|
let repo = resolve_repo(&path)?;
|
|
let remote = validate_remote_name(&repo, &remote, true)?;
|
|
let branch = branch.trim();
|
|
if branch.is_empty() || branch.starts_with('-') {
|
|
return Err("Invalid remote branch name.".to_string());
|
|
}
|
|
run_git(&repo, ["check-ref-format", "--branch", branch])?;
|
|
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}");
|
|
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
|
|
log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}");
|
|
status_for_repo(&repo)
|
|
})();
|
|
if let Err(error) = &result {
|
|
log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}");
|
|
}
|
|
result
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn rename_remote_branch(
|
|
path: String,
|
|
remote: String,
|
|
old_branch: String,
|
|
new_branch: String,
|
|
) -> Result<GitStatus, String> {
|
|
run_git_task("Could not rename remote branch", move || {
|
|
rename_remote_branch_core(path, remote, old_branch, new_branch)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn rename_remote_branch_core(
|
|
path: String,
|
|
remote: String,
|
|
old_branch: String,
|
|
new_branch: String,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let remote = validate_remote_name(&repo, &remote, true)?;
|
|
let old_branch = validate_branch_ref_name(old_branch.trim())?;
|
|
let new_branch = validate_branch_ref_name(new_branch.trim())?;
|
|
|
|
if old_branch == new_branch {
|
|
return Err("The new remote branch name is unchanged.".to_string());
|
|
}
|
|
|
|
let old_tracking_ref = format!("refs/remotes/{remote}/{old_branch}");
|
|
let new_tracking_ref = format!("refs/remotes/{remote}/{new_branch}");
|
|
if !ref_exists(&repo, &old_tracking_ref)? {
|
|
return Err(format!(
|
|
"Remote branch '{remote}/{old_branch}' was not found."
|
|
));
|
|
}
|
|
if ref_exists(&repo, &new_tracking_ref)? {
|
|
return Err(format!(
|
|
"Remote branch '{remote}/{new_branch}' already exists."
|
|
));
|
|
}
|
|
|
|
let old_hash = run_git(&repo, ["rev-parse", "--verify", old_tracking_ref.as_str()])?;
|
|
let old_hash = String::from_utf8_lossy(&old_hash).trim().to_string();
|
|
let old_remote_ref = format!("refs/heads/{old_branch}");
|
|
let new_remote_ref = format!("refs/heads/{new_branch}");
|
|
let source_lease = format!("--force-with-lease={old_remote_ref}:{old_hash}");
|
|
// An empty expected value means the destination must not exist on the remote.
|
|
let destination_lease = format!("--force-with-lease={new_remote_ref}:");
|
|
let create_refspec = format!("{old_tracking_ref}:{new_remote_ref}");
|
|
let delete_refspec = format!(":{old_remote_ref}");
|
|
|
|
// Git has no standalone remote-rename command. Create the new ref and delete
|
|
// the old one in a single atomic push so a rejected update leaves both untouched.
|
|
run_git(
|
|
&repo,
|
|
[
|
|
"push",
|
|
"--atomic",
|
|
source_lease.as_str(),
|
|
destination_lease.as_str(),
|
|
remote.as_str(),
|
|
create_refspec.as_str(),
|
|
delete_refspec.as_str(),
|
|
],
|
|
)?;
|
|
|
|
// Git normally updates remote-tracking refs after a successful push. Keep the
|
|
// local view consistent as a fallback for unusual remote/refspec setups.
|
|
if !ref_exists(&repo, &new_tracking_ref)? {
|
|
if let Err(error) = run_git(
|
|
&repo,
|
|
["update-ref", new_tracking_ref.as_str(), old_hash.as_str()],
|
|
) {
|
|
log::warn!(target: "gitty::remote", "remote rename succeeded, but the new tracking ref could not be updated: {error}");
|
|
}
|
|
}
|
|
if ref_exists(&repo, &old_tracking_ref)? {
|
|
if let Err(error) = run_git(
|
|
&repo,
|
|
[
|
|
"update-ref",
|
|
"-d",
|
|
old_tracking_ref.as_str(),
|
|
old_hash.as_str(),
|
|
],
|
|
) {
|
|
log::warn!(target: "gitty::remote", "remote rename succeeded, but the old tracking ref could not be removed: {error}");
|
|
}
|
|
}
|
|
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
|
run_git_task("Could not load stashes", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
stashes_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
|
run_git_task("Could not load tags", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
tags_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
|
|
let output = run_git(
|
|
repo,
|
|
[
|
|
"for-each-ref",
|
|
"--sort=-creatordate",
|
|
"refs/tags",
|
|
"--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)",
|
|
],
|
|
)?;
|
|
let text = String::from_utf8_lossy(&output);
|
|
let mut tags = Vec::new();
|
|
|
|
for line in text.lines() {
|
|
let mut parts = line.splitn(7, '\0');
|
|
let name = parts.next().unwrap_or_default().trim();
|
|
let object_hash = parts.next().unwrap_or_default().trim();
|
|
let object_short = parts.next().unwrap_or_default().trim();
|
|
let deref_hash = parts.next().unwrap_or_default().trim();
|
|
let deref_short = parts.next().unwrap_or_default().trim();
|
|
let subject = parts.next().unwrap_or_default().trim();
|
|
let date = parts.next().unwrap_or_default().trim();
|
|
|
|
if name.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Annotated tags are their own object with a `taggerdate`/subject and
|
|
// dereference (`*...`) to the commit they point at; lightweight tags
|
|
// point straight at the commit, so the dereferenced fields are empty.
|
|
let annotated = !deref_hash.is_empty();
|
|
let hash = if annotated { deref_hash } else { object_hash };
|
|
let short_hash = if annotated { deref_short } else { object_short };
|
|
|
|
tags.push(GitTag {
|
|
name: name.to_string(),
|
|
hash: hash.to_string(),
|
|
short_hash: short_hash.to_string(),
|
|
message: if annotated && !subject.is_empty() {
|
|
Some(subject.to_string())
|
|
} else {
|
|
None
|
|
},
|
|
date: date.to_string(),
|
|
annotated,
|
|
});
|
|
}
|
|
|
|
Ok(tags)
|
|
}
|
|
|
|
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
|
let output = run_git(
|
|
repo,
|
|
[
|
|
"for-each-ref",
|
|
"--format=%(refname)\t%(HEAD)\t%(upstream:short)",
|
|
"refs/heads",
|
|
"refs/remotes",
|
|
],
|
|
)?;
|
|
let text = String::from_utf8_lossy(&output);
|
|
let mut branches = Vec::new();
|
|
|
|
for line in text.lines() {
|
|
let mut parts = line.splitn(3, '\t');
|
|
let ref_name = parts.next().unwrap_or_default();
|
|
let head_marker = parts.next().unwrap_or_default();
|
|
let configured_upstream = parts.next().unwrap_or_default().trim();
|
|
|
|
let (name, remote) = if let Some(name) = ref_name.strip_prefix("refs/heads/") {
|
|
(name, false)
|
|
} else if let Some(name) = ref_name.strip_prefix("refs/remotes/") {
|
|
if name.ends_with("/HEAD") {
|
|
continue;
|
|
}
|
|
(name, true)
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
branches.push(GitBranch {
|
|
name: name.to_string(),
|
|
current: head_marker.trim() == "*",
|
|
remote,
|
|
upstream: if remote || configured_upstream.is_empty() {
|
|
None
|
|
} else {
|
|
Some(configured_upstream.to_string())
|
|
},
|
|
});
|
|
}
|
|
|
|
Ok(branches)
|
|
}
|
|
|
|
fn stashes_for_repo(repo: &Path) -> Result<Vec<GitStash>, String> {
|
|
let output = run_git(repo, ["stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs"])?;
|
|
let text = String::from_utf8_lossy(&output);
|
|
let mut stashes = Vec::new();
|
|
|
|
for line in text.lines() {
|
|
let mut parts = line.splitn(4, '\0');
|
|
let selector = parts.next().unwrap_or_default().trim();
|
|
let hash = parts.next().unwrap_or_default().trim();
|
|
let date = parts.next().unwrap_or_default().trim();
|
|
let subject = parts.next().unwrap_or_default().trim();
|
|
if selector.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let index = stash_index_from_selector(selector).unwrap_or(stashes.len() as u32);
|
|
let (branch, message) = parse_stash_subject(subject);
|
|
stashes.push(GitStash {
|
|
selector: selector.to_string(),
|
|
index,
|
|
hash: hash.to_string(),
|
|
branch,
|
|
message,
|
|
date: date.to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(stashes)
|
|
}
|
|
|
|
fn stash_index_from_selector(selector: &str) -> Option<u32> {
|
|
selector
|
|
.strip_prefix("stash@{")
|
|
.and_then(|value| value.strip_suffix('}'))
|
|
.and_then(|value| value.parse::<u32>().ok())
|
|
}
|
|
|
|
fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
|
|
for prefix in ["WIP on ", "On "] {
|
|
if let Some(value) = subject.strip_prefix(prefix) {
|
|
if let Some((branch, rest)) = value.split_once(": ") {
|
|
let message = if prefix == "WIP on " {
|
|
rest.split_once(' ').map(|(_, msg)| msg).unwrap_or(rest)
|
|
} else {
|
|
rest
|
|
};
|
|
return (Some(branch.to_string()), message.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
(None, subject.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stash_push(
|
|
path: String,
|
|
message: Option<String>,
|
|
include_untracked: bool,
|
|
) -> Result<GitStatus, String> {
|
|
run_git_task("Could not stash changes", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
let trimmed_message = message.unwrap_or_default().trim().to_string();
|
|
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
|
|
if include_untracked {
|
|
args.push(OsString::from("--include-untracked"));
|
|
}
|
|
if !trimmed_message.is_empty() {
|
|
args.push(OsString::from("-m"));
|
|
args.push(OsString::from(trimmed_message));
|
|
}
|
|
|
|
run_git(&repo, args)?;
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
|
|
run_git_task("Could not apply stash", move || {
|
|
run_stash_update(path, "apply", selector)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
|
|
run_git_task("Could not pop stash", move || {
|
|
run_stash_update(path, "pop", selector)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
|
|
run_git_task("Could not drop stash", move || {
|
|
run_stash_update(path, "drop", selector)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let selector = validate_stash_selector(&selector)?;
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["stash", action, selector.as_str()])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if output.status.success() {
|
|
return status_for_repo(&repo);
|
|
}
|
|
|
|
let status = status_for_repo(&repo)?;
|
|
if has_unresolved_conflicts(&status) {
|
|
return Ok(status);
|
|
}
|
|
|
|
Err(format!(
|
|
"Git command failed: {}",
|
|
command_output_details(&output)
|
|
))
|
|
}
|
|
|
|
fn validate_stash_selector(selector: &str) -> Result<String, String> {
|
|
let selector = selector.trim();
|
|
let Some(index) = stash_index_from_selector(selector) else {
|
|
return Err("Invalid stash selector.".to_string());
|
|
};
|
|
Ok(format!("stash@{{{index}}}"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
|
run_git_task("Could not check out branch", move || {
|
|
checkout_branch_core(path, branch)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn checkout_branch_core(path: String, branch: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let branch = branch.trim().to_string();
|
|
if branch.is_empty() {
|
|
return Err("Branch name must not be empty.".to_string());
|
|
}
|
|
|
|
match checkout_plan(&repo, &branch)? {
|
|
CheckoutPlan::Local(local) => {
|
|
run_git(&repo, ["checkout", local.as_str()])?;
|
|
}
|
|
CheckoutPlan::TrackRemote { local, remote } => {
|
|
run_git(
|
|
&repo,
|
|
["checkout", "--track", "-b", local.as_str(), remote.as_str()],
|
|
)?;
|
|
}
|
|
CheckoutPlan::Raw(target) => {
|
|
run_git(&repo, ["checkout", target.as_str()])?;
|
|
}
|
|
}
|
|
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_branch(
|
|
path: String,
|
|
branch: String,
|
|
start_point: Option<String>,
|
|
) -> Result<GitStatus, String> {
|
|
run_git_task("Could not create branch", move || {
|
|
create_branch_core(path, branch, start_point)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn create_branch_core(
|
|
path: String,
|
|
branch: String,
|
|
start_point: Option<String>,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let branch = validate_new_branch_name(&repo, &branch)?;
|
|
match start_point {
|
|
Some(start) if !start.trim().is_empty() => {
|
|
// Resolve the requested commit first so we fail clearly if it is gone.
|
|
let start = verify_commit(&repo, &start)?;
|
|
run_git(&repo, ["checkout", "-b", branch.as_str(), start.as_str()])?;
|
|
}
|
|
_ => {
|
|
run_git(&repo, ["checkout", "-b", branch.as_str()])?;
|
|
}
|
|
}
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn rename_branch(
|
|
path: String,
|
|
old_branch: String,
|
|
new_branch: String,
|
|
) -> Result<GitStatus, String> {
|
|
run_git_task("Could not rename branch", move || {
|
|
rename_branch_core(path, old_branch, new_branch)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn rename_branch_core(
|
|
path: String,
|
|
old_branch: String,
|
|
new_branch: String,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let old_branch = validate_existing_local_branch_name(&repo, &old_branch)?;
|
|
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
|
|
|
|
run_git(
|
|
&repo,
|
|
[
|
|
"branch",
|
|
"-m",
|
|
"--",
|
|
old_branch.as_str(),
|
|
new_branch.as_str(),
|
|
],
|
|
)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_branch(
|
|
path: String,
|
|
branch: String,
|
|
force: Option<bool>,
|
|
) -> Result<GitStatus, String> {
|
|
run_git_task("Could not delete branch", move || {
|
|
delete_branch_core(path, branch, force)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn delete_branch_core(
|
|
path: String,
|
|
branch: String,
|
|
force: Option<bool>,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let branch = validate_existing_local_branch_name(&repo, &branch)?;
|
|
let status = status_for_repo(&repo)?;
|
|
if status.current_branch.as_deref() == Some(branch.as_str()) {
|
|
return Err("The current branch cannot be deleted.".to_string());
|
|
}
|
|
|
|
let delete_flag = if force.unwrap_or(false) { "-D" } else { "-d" };
|
|
run_git(&repo, ["branch", delete_flag, "--", branch.as_str()])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
fn worktree_path_matches(left: &Path, right: &Path) -> bool {
|
|
match (fs::canonicalize(left), fs::canonicalize(right)) {
|
|
(Ok(left), Ok(right)) => left == right,
|
|
_ => left == right,
|
|
}
|
|
}
|
|
|
|
fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec<GitWorktree> {
|
|
#[derive(Default)]
|
|
struct Record {
|
|
path: Option<String>,
|
|
head: Option<String>,
|
|
branch: Option<String>,
|
|
bare: bool,
|
|
detached: bool,
|
|
locked: bool,
|
|
lock_reason: Option<String>,
|
|
prunable: bool,
|
|
prune_reason: Option<String>,
|
|
}
|
|
|
|
fn finish_record(rows: &mut Vec<GitWorktree>, record: &mut Record, current_repo: &Path) {
|
|
let Some(path) = record.path.take() else {
|
|
*record = Record::default();
|
|
return;
|
|
};
|
|
let path_buf = PathBuf::from(&path);
|
|
let missing = !path_buf.exists();
|
|
let is_current = worktree_path_matches(&path_buf, current_repo);
|
|
let head = record.head.take();
|
|
let short_head = head
|
|
.as_ref()
|
|
.map(|value| value.chars().take(8).collect::<String>());
|
|
rows.push(GitWorktree {
|
|
path,
|
|
head,
|
|
short_head,
|
|
branch: record.branch.take(),
|
|
bare: record.bare,
|
|
detached: record.detached,
|
|
locked: record.locked,
|
|
lock_reason: record.lock_reason.take(),
|
|
prunable: record.prunable,
|
|
prune_reason: record.prune_reason.take(),
|
|
missing,
|
|
is_main: rows.is_empty(),
|
|
is_current,
|
|
clean: true,
|
|
changed_files: 0,
|
|
});
|
|
*record = Record::default();
|
|
}
|
|
|
|
let mut rows = Vec::new();
|
|
let mut record = Record::default();
|
|
for field in output.split(|byte| *byte == 0) {
|
|
if field.is_empty() {
|
|
finish_record(&mut rows, &mut record, current_repo);
|
|
continue;
|
|
}
|
|
let value = String::from_utf8_lossy(field);
|
|
let (key, detail) = value
|
|
.split_once(' ')
|
|
.map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail)));
|
|
match key {
|
|
"worktree" => record.path = detail.map(str::to_string),
|
|
"HEAD" => record.head = detail.map(str::to_string),
|
|
"branch" => {
|
|
record.branch = detail.map(|branch| {
|
|
branch
|
|
.strip_prefix("refs/heads/")
|
|
.unwrap_or(branch)
|
|
.to_string()
|
|
})
|
|
}
|
|
"bare" => record.bare = true,
|
|
"detached" => record.detached = true,
|
|
"locked" => {
|
|
record.locked = true;
|
|
record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
|
|
}
|
|
"prunable" => {
|
|
record.prunable = true;
|
|
record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
finish_record(&mut rows, &mut record, current_repo);
|
|
rows
|
|
}
|
|
|
|
fn worktrees_for_repo(repo: &Path) -> Result<Vec<GitWorktree>, String> {
|
|
let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?;
|
|
let mut worktrees = parse_worktree_porcelain(&output, repo);
|
|
for worktree in &mut worktrees {
|
|
if worktree.missing {
|
|
worktree.clean = false;
|
|
continue;
|
|
}
|
|
if worktree.bare {
|
|
continue;
|
|
}
|
|
match run_git_at(
|
|
Path::new(&worktree.path),
|
|
["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
|
|
"Could not inspect worktree status",
|
|
) {
|
|
Ok(status) => {
|
|
worktree.changed_files = status
|
|
.split(|byte| *byte == 0)
|
|
.filter(|entry| entry.len() >= 3 && entry[2] == b' ')
|
|
.count() as u32;
|
|
worktree.clean = worktree.changed_files == 0;
|
|
}
|
|
Err(_) => {
|
|
worktree.clean = false;
|
|
}
|
|
}
|
|
}
|
|
Ok(worktrees)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not load worktrees", move || {
|
|
list_worktrees_core(path)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn list_worktrees_core(path: String) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn add_worktree(
|
|
path: String,
|
|
worktree_path: String,
|
|
branch: Option<String>,
|
|
new_branch: Option<String>,
|
|
start_point: Option<String>,
|
|
detached: Option<bool>,
|
|
lock: Option<bool>,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not add worktree", move || {
|
|
add_worktree_core(
|
|
path,
|
|
worktree_path,
|
|
branch,
|
|
new_branch,
|
|
start_point,
|
|
detached,
|
|
lock,
|
|
)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn add_worktree_core(
|
|
path: String,
|
|
worktree_path: String,
|
|
branch: Option<String>,
|
|
new_branch: Option<String>,
|
|
start_point: Option<String>,
|
|
detached: Option<bool>,
|
|
lock: Option<bool>,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let destination = worktree_path.trim();
|
|
if destination.is_empty() {
|
|
return Err("Choose a folder for the new worktree.".to_string());
|
|
}
|
|
let branch = branch
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty());
|
|
let new_branch = new_branch
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty());
|
|
if branch.is_some() && new_branch.is_some() {
|
|
return Err("Choose either an existing branch or a new branch.".to_string());
|
|
}
|
|
|
|
let mut args = vec![OsString::from("worktree"), OsString::from("add")];
|
|
if lock.unwrap_or(false) {
|
|
args.push(OsString::from("--lock"));
|
|
}
|
|
if detached.unwrap_or(false) {
|
|
args.push(OsString::from("--detach"));
|
|
}
|
|
|
|
let target = if let Some(new_branch) = new_branch {
|
|
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
|
|
args.push(OsString::from("-b"));
|
|
args.push(OsString::from(new_branch));
|
|
start_point
|
|
.as_deref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(|value| verify_commit(&repo, value))
|
|
.transpose()?
|
|
} else if let Some(branch) = branch {
|
|
Some(validate_existing_local_branch_name(&repo, &branch)?)
|
|
} else {
|
|
start_point
|
|
.as_deref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(|value| verify_commit(&repo, value))
|
|
.transpose()?
|
|
};
|
|
|
|
args.push(OsString::from(destination));
|
|
if let Some(target) = target {
|
|
args.push(OsString::from(target));
|
|
}
|
|
run_git(&repo, args)?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn remove_worktree(
|
|
path: String,
|
|
worktree_path: String,
|
|
force: Option<bool>,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not remove worktree", move || {
|
|
remove_worktree_core(path, worktree_path, force)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn remove_worktree_core(
|
|
path: String,
|
|
worktree_path: String,
|
|
force: Option<bool>,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let worktrees = worktrees_for_repo(&repo)?;
|
|
let target = worktrees
|
|
.iter()
|
|
.find(|worktree| {
|
|
worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path))
|
|
})
|
|
.ok_or_else(|| "The selected worktree is no longer registered.".to_string())?;
|
|
if target.is_main {
|
|
return Err("The main worktree cannot be removed.".to_string());
|
|
}
|
|
if target.is_current {
|
|
return Err("The currently open worktree cannot be removed.".to_string());
|
|
}
|
|
if target.locked {
|
|
return Err("Unlock this worktree before removing it.".to_string());
|
|
}
|
|
let mut args = vec![OsString::from("worktree"), OsString::from("remove")];
|
|
if force.unwrap_or(false) {
|
|
args.push(OsString::from("--force"));
|
|
}
|
|
args.push(OsString::from(worktree_path));
|
|
run_git(&repo, args)?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn move_worktree(
|
|
path: String,
|
|
worktree_path: String,
|
|
destination: String,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not move worktree", move || {
|
|
move_worktree_core(path, worktree_path, destination)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn move_worktree_core(
|
|
path: String,
|
|
worktree_path: String,
|
|
destination: String,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let destination = destination.trim();
|
|
if destination.is_empty() {
|
|
return Err("Choose a new location for the worktree.".to_string());
|
|
}
|
|
run_git(
|
|
&repo,
|
|
[
|
|
OsString::from("worktree"),
|
|
OsString::from("move"),
|
|
OsString::from(worktree_path),
|
|
OsString::from(destination),
|
|
],
|
|
)?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn lock_worktree(
|
|
path: String,
|
|
worktree_path: String,
|
|
reason: Option<String>,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not lock worktree", move || {
|
|
lock_worktree_core(path, worktree_path, reason)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn lock_worktree_core(
|
|
path: String,
|
|
worktree_path: String,
|
|
reason: Option<String>,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let mut args = vec![OsString::from("worktree"), OsString::from("lock")];
|
|
if let Some(reason) = reason
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
args.push(OsString::from("--reason"));
|
|
args.push(OsString::from(reason));
|
|
}
|
|
args.push(OsString::from(worktree_path));
|
|
run_git(&repo, args)?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn unlock_worktree(
|
|
path: String,
|
|
worktree_path: String,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not unlock worktree", move || {
|
|
unlock_worktree_core(path, worktree_path)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn unlock_worktree_core(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not prune worktrees", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
run_git(&repo, ["worktree", "prune"])?;
|
|
worktrees_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn repair_worktree(
|
|
path: String,
|
|
worktree_path: String,
|
|
) -> Result<Vec<GitWorktree>, String> {
|
|
run_git_task("Could not repair worktree", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
|
|
worktrees_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_tag(
|
|
path: String,
|
|
name: String,
|
|
target: Option<String>,
|
|
message: Option<String>,
|
|
) -> Result<Vec<GitTag>, String> {
|
|
run_git_task("Could not create tag", move || {
|
|
create_tag_core(path, name, target, message)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn create_tag_core(
|
|
path: String,
|
|
name: String,
|
|
target: Option<String>,
|
|
message: Option<String>,
|
|
) -> Result<Vec<GitTag>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let name = validate_new_tag_name(&repo, &name)?;
|
|
let target = match target {
|
|
Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?,
|
|
_ => verify_commit(&repo, "HEAD")?,
|
|
};
|
|
let message = message
|
|
.map(|message| message.trim().to_string())
|
|
.filter(|message| !message.is_empty());
|
|
|
|
match message {
|
|
Some(message) => {
|
|
run_git(
|
|
&repo,
|
|
[
|
|
"tag",
|
|
"-a",
|
|
name.as_str(),
|
|
"-m",
|
|
message.as_str(),
|
|
target.as_str(),
|
|
],
|
|
)?;
|
|
}
|
|
None => {
|
|
run_git(&repo, ["tag", name.as_str(), target.as_str()])?;
|
|
}
|
|
}
|
|
|
|
tags_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
|
run_git_task("Could not delete tag", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
let name = validate_existing_tag_name(&repo, &name)?;
|
|
run_git(&repo, ["tag", "-d", name.as_str()])?;
|
|
tags_for_repo(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn push_tag(
|
|
path: String,
|
|
name: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
) -> Result<(), String> {
|
|
run_git_task("Could not push tag", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
let name = validate_existing_tag_name(&repo, &name)?;
|
|
let remote = initial_push_remote_name(&repo)?;
|
|
let tag_ref = format!("refs/tags/{name}");
|
|
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
|
|
|
|
match (username.as_deref(), password.as_deref()) {
|
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
|
run_git_authenticated(&repo, push_args, u, p)?;
|
|
}
|
|
_ => {
|
|
run_git(&repo, push_args)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
|
let name = name.trim();
|
|
if name.is_empty() {
|
|
return Err("Tag name must not be empty.".to_string());
|
|
}
|
|
|
|
let normalized = validate_tag_ref_name(name)?;
|
|
if ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
|
return Err(format!("Tag '{normalized}' already exists."));
|
|
}
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
|
let name = name.trim();
|
|
if name.is_empty() {
|
|
return Err("Tag name must not be empty.".to_string());
|
|
}
|
|
|
|
let normalized = validate_tag_ref_name(name)?;
|
|
if !ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
|
return Err(format!("Tag '{normalized}' was not found."));
|
|
}
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
|
let output = git_command()
|
|
.args([
|
|
"check-ref-format",
|
|
"--allow-onelevel",
|
|
&format!("refs/tags/{name}"),
|
|
])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if !output.status.success() {
|
|
let details = command_output_details(&output);
|
|
return Err(format!("Invalid tag name: {details}"));
|
|
}
|
|
|
|
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let normalized = normalized
|
|
.strip_prefix("refs/tags/")
|
|
.map(str::to_string)
|
|
.unwrap_or(normalized);
|
|
|
|
Ok(if normalized.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
normalized
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(&files)?;
|
|
|
|
if !files.is_empty() {
|
|
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
|
|
// to be staged with both its old and new path so `git add` records it as a rename
|
|
// instead of leaving the old path's deletion unstaged.
|
|
let current_status = status_for_repo(&repo)?;
|
|
let mut add_paths: Vec<String> = Vec::new();
|
|
for file in &files {
|
|
match find_status(¤t_status.files, file) {
|
|
Some(entry) => {
|
|
if let Some(old_path) = &entry.old_path {
|
|
add_paths.push(old_path.clone());
|
|
}
|
|
add_paths.push(entry.path.clone());
|
|
}
|
|
None => add_paths.push(file.clone()),
|
|
}
|
|
}
|
|
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
|
}
|
|
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not stage files: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(&files)?;
|
|
|
|
if !files.is_empty() {
|
|
let current_status = status_for_repo(&repo)?;
|
|
unstage_selected_files(&repo, ¤t_status.files, &files)?;
|
|
}
|
|
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not unstage files: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn restore_files(
|
|
path: String,
|
|
files: Vec<String>,
|
|
staged: bool,
|
|
) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(&files)?;
|
|
|
|
if files.is_empty() {
|
|
return status_for_repo(&repo);
|
|
}
|
|
|
|
let current_status = status_for_repo(&repo)?;
|
|
if staged {
|
|
restore_staged_files(&repo, ¤t_status.files, &files)?;
|
|
} else {
|
|
restore_worktree_files(&repo, ¤t_status.files, &files)?;
|
|
}
|
|
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not restore files: {err}"))?
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
|
|
let base_args = if staged {
|
|
&[
|
|
"diff",
|
|
"--cached",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
"--unified=3",
|
|
][..]
|
|
} else {
|
|
&["diff", "--no-ext-diff", "--no-textconv", "--unified=3"][..]
|
|
};
|
|
let output = run_git_with_paths(&repo, base_args, &[file])?;
|
|
Ok(String::from_utf8_lossy(&output).to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn commit_ai_local_models() -> Vec<commit_ai::LocalModelOption> {
|
|
commit_ai::LOCAL_MODELS.to_vec()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn commit_ai_status(
|
|
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
|
) -> Result<commit_ai::CommitAiStatus, String> {
|
|
Ok(engine.status().await)
|
|
}
|
|
|
|
/// Kicks off the (first-run-only) download and model load in the background and returns
|
|
/// immediately; the frontend polls `commit_ai_status` to know when it's ready.
|
|
#[tauri::command]
|
|
pub fn commit_ai_load(model_id: String, engine: tauri::State<'_, commit_ai::CommitAiEngine>) {
|
|
let engine = engine.inner().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
engine.ensure_loaded(&model_id).await;
|
|
});
|
|
}
|
|
|
|
// The prompt is built from `git diff --cached` only, i.e. exactly the staged changes —
|
|
// unstaged edits and untracked files never influence the generated message.
|
|
fn staged_diff(repo: &Path) -> Result<String, String> {
|
|
// Full staged file list (nothing excluded) so the model knows the complete scope
|
|
// even when the detailed diff below is filtered or truncated for context size.
|
|
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
|
|
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
|
|
|
|
// Generated lockfiles say nothing useful about intent but easily blow the small
|
|
// context window of local models, so keep them out of the detailed diff.
|
|
let diff = run_git(
|
|
repo,
|
|
[
|
|
"diff",
|
|
"--cached",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
"--unified=3",
|
|
"--",
|
|
".",
|
|
":(exclude)*package-lock.json",
|
|
":(exclude)*pnpm-lock.yaml",
|
|
":(exclude)*yarn.lock",
|
|
":(exclude)*bun.lockb",
|
|
":(exclude)*Cargo.lock",
|
|
":(exclude)*composer.lock",
|
|
":(exclude)*Gemfile.lock",
|
|
":(exclude)*poetry.lock",
|
|
":(exclude)*go.sum",
|
|
],
|
|
)?;
|
|
let diff = String::from_utf8_lossy(&diff);
|
|
|
|
if file_list.is_empty() {
|
|
return Ok(diff.to_string());
|
|
}
|
|
Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
|
|
}
|
|
|
|
fn staged_diff_local(
|
|
repo: &Path,
|
|
profile: commit_ai::LocalGenerationProfile,
|
|
) -> Result<String, String> {
|
|
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
|
|
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
|
|
|
|
let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?;
|
|
let stat = String::from_utf8_lossy(&stat).trim().to_string();
|
|
|
|
let diff_args = vec![
|
|
"diff",
|
|
"--cached",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
profile.diff_unified_context(),
|
|
"--",
|
|
".",
|
|
":(exclude)*package-lock.json",
|
|
":(exclude)*pnpm-lock.yaml",
|
|
":(exclude)*yarn.lock",
|
|
":(exclude)*bun.lockb",
|
|
":(exclude)*Cargo.lock",
|
|
":(exclude)*composer.lock",
|
|
":(exclude)*Gemfile.lock",
|
|
":(exclude)*poetry.lock",
|
|
":(exclude)*go.sum",
|
|
];
|
|
let diff = run_git(repo, diff_args)?;
|
|
let diff = String::from_utf8_lossy(&diff).trim().to_string();
|
|
|
|
let mut sections = Vec::new();
|
|
if !file_list.is_empty() {
|
|
sections.push(format!("Staged files:\n{file_list}"));
|
|
}
|
|
if !stat.is_empty() {
|
|
sections.push(format!("Diff stat:\n{stat}"));
|
|
}
|
|
if !diff.is_empty() {
|
|
sections.push(format!("Detailed diff:\n{diff}"));
|
|
}
|
|
Ok(sections.join("\n\n"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn commit_ai_generate(
|
|
path: String,
|
|
notes: Option<String>,
|
|
provider: String,
|
|
model: Option<String>,
|
|
api_key: Option<String>,
|
|
base_url: Option<String>,
|
|
local_profile: Option<String>,
|
|
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
|
) -> Result<String, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref());
|
|
let diff = if provider == "local" {
|
|
staged_diff_local(&repo, local_profile)?
|
|
} else {
|
|
staged_diff(&repo)?
|
|
};
|
|
let notes = notes.as_deref();
|
|
let model = model.filter(|value| !value.trim().is_empty());
|
|
let api_key = api_key.filter(|value| !value.trim().is_empty());
|
|
let base_url = base_url.filter(|value| !value.trim().is_empty());
|
|
|
|
match provider.as_str() {
|
|
"local" => {
|
|
engine
|
|
.generate_commit_message(&diff, notes, local_profile)
|
|
.await
|
|
}
|
|
"openai" => {
|
|
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
|
|
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
|
commit_ai::generate_openai(&api_key, &model, &diff, notes).await
|
|
}
|
|
"anthropic" => {
|
|
let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?;
|
|
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
|
|
commit_ai::generate_anthropic(&api_key, &model, &diff, notes).await
|
|
}
|
|
"custom" => {
|
|
let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?;
|
|
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
|
|
commit_ai::generate_custom(&base_url, api_key.as_deref(), &model, &diff, notes).await
|
|
}
|
|
other => Err(format!("Unknown AI provider: {other}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AiReviewRisk {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AiReviewSeverity {
|
|
Critical,
|
|
Warning,
|
|
Info,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiReviewFinding {
|
|
pub severity: AiReviewSeverity,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub file: Option<String>,
|
|
pub line: Option<u32>,
|
|
pub suggestion: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiReviewResult {
|
|
pub summary: String,
|
|
pub risk: AiReviewRisk,
|
|
pub findings: Vec<AiReviewFinding>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiCommitGroup {
|
|
pub message: String,
|
|
pub reason: String,
|
|
pub files: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiCommitPlan {
|
|
pub summary: String,
|
|
pub groups: Vec<AiCommitGroup>,
|
|
}
|
|
|
|
fn parse_ai_commit_plan(raw: &str, staged_files: &[String]) -> Result<AiCommitPlan, String> {
|
|
use std::collections::HashSet;
|
|
let trimmed = raw.trim().trim_matches('`').trim();
|
|
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
|
|
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
|
|
_ => return Err("The AI response did not contain a valid commit plan.".to_string()),
|
|
};
|
|
let mut plan: AiCommitPlan = serde_json::from_str(json)
|
|
.map_err(|error| format!("Could not process the commit plan: {error}"))?;
|
|
plan.groups
|
|
.retain(|group| !group.message.trim().is_empty() && !group.files.is_empty());
|
|
if plan.groups.len() < 2 {
|
|
return Err("The staged changes do not appear to benefit from splitting.".to_string());
|
|
}
|
|
if plan.groups.len() > 12 {
|
|
return Err("The AI proposed too many commit groups.".to_string());
|
|
}
|
|
let expected = staged_files.iter().cloned().collect::<HashSet<_>>();
|
|
let mut seen = HashSet::new();
|
|
for group in &mut plan.groups {
|
|
group.message = group.message.trim().to_string();
|
|
group.reason = group.reason.trim().to_string();
|
|
group
|
|
.files
|
|
.retain(|file| expected.contains(file) && seen.insert(file.clone()));
|
|
if group.files.is_empty() {
|
|
return Err("The AI returned an empty or duplicate commit group.".to_string());
|
|
}
|
|
}
|
|
if seen != expected {
|
|
return Err("The AI plan did not assign every staged file exactly once.".to_string());
|
|
}
|
|
plan.summary = plan.summary.trim().to_string();
|
|
Ok(plan)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn commit_ai_split(
|
|
path: String,
|
|
provider: String,
|
|
model: Option<String>,
|
|
api_key: Option<String>,
|
|
base_url: Option<String>,
|
|
) -> Result<AiCommitPlan, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let status = status_for_repo(&repo)?;
|
|
let staged_files = status
|
|
.files
|
|
.iter()
|
|
.filter(|file| file.staged.is_some())
|
|
.map(|file| file.path.clone())
|
|
.collect::<Vec<_>>();
|
|
if staged_files.len() < 2 {
|
|
return Err("Stage at least two files before creating a split plan.".to_string());
|
|
}
|
|
if status
|
|
.files
|
|
.iter()
|
|
.any(|file| file.staged.is_some() && file.unstaged.is_some())
|
|
{
|
|
return Err("Files with both staged and unstaged changes cannot be split safely. Stage or discard the remaining changes first.".to_string());
|
|
}
|
|
let diff = staged_diff(&repo)?;
|
|
let model = model.filter(|value| !value.trim().is_empty());
|
|
let api_key = api_key.filter(|value| !value.trim().is_empty());
|
|
let base_url = base_url.filter(|value| !value.trim().is_empty());
|
|
let raw = match provider.as_str() {
|
|
"openai" => {
|
|
commit_ai::split_openai(
|
|
api_key
|
|
.as_deref()
|
|
.ok_or_else(|| "OpenAI API key is missing.".to_string())?,
|
|
model.as_deref().unwrap_or("gpt-4o-mini"),
|
|
&diff,
|
|
)
|
|
.await?
|
|
}
|
|
"anthropic" => {
|
|
commit_ai::split_anthropic(
|
|
api_key
|
|
.as_deref()
|
|
.ok_or_else(|| "Anthropic API key is missing.".to_string())?,
|
|
model.as_deref().unwrap_or("claude-3-5-haiku-latest"),
|
|
&diff,
|
|
)
|
|
.await?
|
|
}
|
|
"custom" => {
|
|
commit_ai::split_custom(
|
|
base_url
|
|
.as_deref()
|
|
.ok_or_else(|| "Endpoint URL is missing.".to_string())?,
|
|
api_key.as_deref(),
|
|
model
|
|
.as_deref()
|
|
.ok_or_else(|| "Model name is missing.".to_string())?,
|
|
&diff,
|
|
)
|
|
.await?
|
|
}
|
|
"local" => return Err("Commit splitting currently requires an API provider.".to_string()),
|
|
other => return Err(format!("Unknown AI provider: {other}")),
|
|
};
|
|
parse_ai_commit_plan(&raw, &staged_files)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AiReviewWireFinding {
|
|
severity: String,
|
|
title: String,
|
|
description: String,
|
|
file: Option<String>,
|
|
line: Option<u32>,
|
|
suggestion: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AiReviewWireResult {
|
|
summary: String,
|
|
risk: String,
|
|
#[serde(default)]
|
|
findings: Vec<AiReviewWireFinding>,
|
|
}
|
|
|
|
fn parse_ai_review(raw: &str) -> Result<AiReviewResult, String> {
|
|
let trimmed = raw.trim().trim_matches('`').trim();
|
|
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
|
|
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
|
|
_ => return Err("The AI review did not contain valid JSON.".to_string()),
|
|
};
|
|
let wire: AiReviewWireResult = serde_json::from_str(json)
|
|
.map_err(|error| format!("Could not process the AI review: {error}"))?;
|
|
let risk = match wire.risk.trim().to_ascii_lowercase().as_str() {
|
|
"high" => AiReviewRisk::High,
|
|
"medium" => AiReviewRisk::Medium,
|
|
_ => AiReviewRisk::Low,
|
|
};
|
|
let mut findings = wire
|
|
.findings
|
|
.into_iter()
|
|
.filter(|finding| {
|
|
!finding.title.trim().is_empty() && !finding.description.trim().is_empty()
|
|
})
|
|
.map(|finding| AiReviewFinding {
|
|
severity: match finding.severity.trim().to_ascii_lowercase().as_str() {
|
|
"critical" | "error" | "high" => AiReviewSeverity::Critical,
|
|
"warning" | "warn" | "medium" => AiReviewSeverity::Warning,
|
|
_ => AiReviewSeverity::Info,
|
|
},
|
|
title: finding.title.trim().to_string(),
|
|
description: finding.description.trim().to_string(),
|
|
file: finding.file.filter(|file| !file.trim().is_empty()),
|
|
line: finding.line,
|
|
suggestion: finding.suggestion.trim().to_string(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
findings.sort_by_key(|finding| match finding.severity {
|
|
AiReviewSeverity::Critical => 0,
|
|
AiReviewSeverity::Warning => 1,
|
|
AiReviewSeverity::Info => 2,
|
|
});
|
|
findings.truncate(12);
|
|
Ok(AiReviewResult {
|
|
summary: if wire.summary.trim().is_empty() {
|
|
"Review completed.".to_string()
|
|
} else {
|
|
wire.summary.trim().to_string()
|
|
},
|
|
risk,
|
|
findings,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn commit_ai_review(
|
|
path: String,
|
|
provider: String,
|
|
model: Option<String>,
|
|
api_key: Option<String>,
|
|
base_url: Option<String>,
|
|
) -> Result<AiReviewResult, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let diff = staged_diff(&repo)?;
|
|
let model = model.filter(|value| !value.trim().is_empty());
|
|
let api_key = api_key.filter(|value| !value.trim().is_empty());
|
|
let base_url = base_url.filter(|value| !value.trim().is_empty());
|
|
let raw = match provider.as_str() {
|
|
"openai" => {
|
|
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
|
|
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
|
commit_ai::review_openai(&api_key, &model, &diff).await?
|
|
}
|
|
"anthropic" => {
|
|
let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?;
|
|
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
|
|
commit_ai::review_anthropic(&api_key, &model, &diff).await?
|
|
}
|
|
"custom" => {
|
|
let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?;
|
|
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
|
|
commit_ai::review_custom(&base_url, api_key.as_deref(), &model, &diff).await?
|
|
}
|
|
"local" => return Err("Pre-commit review currently requires an API provider.".to_string()),
|
|
other => return Err(format!("Unknown AI provider: {other}")),
|
|
};
|
|
parse_ai_review(&raw)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn apply_file_patch(
|
|
path: String,
|
|
file: String,
|
|
patch: String,
|
|
action: String,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
if patch.trim().is_empty() {
|
|
return Err("No patch selected.".to_string());
|
|
}
|
|
|
|
let patch_path = write_temp_patch(&patch)?;
|
|
let result = match action.as_str() {
|
|
"stage" => check_apply_patch(&repo, &patch_path, &["--cached"])
|
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])),
|
|
"unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
|
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])),
|
|
"discard-unstaged" => check_apply_patch(&repo, &patch_path, &["--reverse"])
|
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
|
|
"discard-staged" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
|
|
.and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"]))
|
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]))
|
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
|
|
_ => Err("Invalid patch action.".to_string()),
|
|
};
|
|
|
|
let _ = std::fs::remove_file(&patch_path);
|
|
result?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if message.trim().is_empty() {
|
|
return Err("Commit message must not be empty.".to_string());
|
|
}
|
|
|
|
let current_status = status_for_repo(&repo)?;
|
|
if has_unresolved_conflicts(¤t_status) {
|
|
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
|
}
|
|
|
|
run_git(&repo, ["commit", "-m", message.as_str()])?;
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not commit: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if verify_commit(&repo, "HEAD").is_err() {
|
|
return Err("There is no commit to amend.".to_string());
|
|
}
|
|
|
|
let current_status = status_for_repo(&repo)?;
|
|
if has_unresolved_conflicts(¤t_status) {
|
|
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
|
}
|
|
|
|
let message = message
|
|
.map(|message| message.trim().to_string())
|
|
.filter(|message| !message.is_empty());
|
|
|
|
match message {
|
|
Some(message) => {
|
|
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
|
|
}
|
|
None => {
|
|
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
|
|
}
|
|
}
|
|
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not amend commit: {err}"))?
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if verify_commit(&repo, "HEAD").is_err() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
|
|
let message = String::from_utf8_lossy(&output).trim_end().to_string();
|
|
Ok(if message.is_empty() {
|
|
None
|
|
} else {
|
|
Some(message)
|
|
})
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if verify_commit(&repo, "HEAD").is_err() {
|
|
return Err("There is no commit to undo.".to_string());
|
|
}
|
|
if verify_commit(&repo, "HEAD~1").is_err() {
|
|
return Err("This is the first commit; there is nothing to undo to.".to_string());
|
|
}
|
|
|
|
// Mixed reset: moves HEAD back one commit and unstages the difference, but
|
|
// leaves the working tree files untouched, so the undone commit's changes
|
|
// reappear as ordinary uncommitted changes instead of being discarded.
|
|
run_git(&repo, ["reset", "HEAD~1"])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn pull(
|
|
path: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
strategy: Option<String>,
|
|
remote: Option<String>,
|
|
branch: Option<String>,
|
|
) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let strategy = strategy.as_deref().unwrap_or("merge");
|
|
let mut pull_args = vec![OsString::from("pull")];
|
|
match strategy {
|
|
"merge" => {
|
|
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")])
|
|
}
|
|
"rebase" => pull_args.push(OsString::from("--rebase")),
|
|
"ff-only" => pull_args.push(OsString::from("--ff-only")),
|
|
_ => return Err("Unknown pull strategy.".to_string()),
|
|
}
|
|
if let Some(remote) = remote
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
{
|
|
validate_remote_name(&repo, &remote, true)?;
|
|
pull_args.push(remote.into());
|
|
if let Some(branch) = branch
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
{
|
|
pull_args.push(branch.into());
|
|
}
|
|
}
|
|
let output = match (username.as_deref(), password.as_deref()) {
|
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
|
run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
|
|
}
|
|
_ => git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(&pull_args)
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
|
};
|
|
|
|
if output.status.success() {
|
|
return status_for_repo(&repo);
|
|
}
|
|
|
|
let status = status_for_repo(&repo)?;
|
|
if has_unresolved_conflicts(&status) {
|
|
return Ok(status);
|
|
}
|
|
|
|
let details = command_output_details(&output);
|
|
if is_auth_error(&details) {
|
|
return Err(format!("AUTH_FAILED:{details}"));
|
|
}
|
|
Err(format!("Git command failed: {details}"))
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not pull: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn fetch(
|
|
path: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
prune: Option<bool>,
|
|
remote: Option<String>,
|
|
) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let mut fetch_args = vec![OsString::from("fetch")];
|
|
if prune.unwrap_or(false) {
|
|
fetch_args.push(OsString::from("--prune"));
|
|
}
|
|
if let Some(remote) = remote
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
{
|
|
validate_remote_name(&repo, &remote, true)?;
|
|
fetch_args.push(remote.into());
|
|
}
|
|
let output = match (username.as_deref(), password.as_deref()) {
|
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
|
run_git_authenticated_output(&repo, fetch_args.clone(), u, p)?
|
|
}
|
|
_ => git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(&fetch_args)
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
|
};
|
|
|
|
if output.status.success() {
|
|
return status_for_repo(&repo);
|
|
}
|
|
|
|
let details = command_output_details(&output);
|
|
if is_auth_error(&details) {
|
|
return Err(format!("AUTH_FAILED:{details}"));
|
|
}
|
|
Err(format!("Git command failed: {details}"))
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not fetch repository: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn push(
|
|
path: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
force_with_lease: Option<bool>,
|
|
remote: Option<String>,
|
|
) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?;
|
|
if force_with_lease.unwrap_or(false) {
|
|
push_args.insert(1, OsString::from("--force-with-lease"));
|
|
}
|
|
match (username.as_deref(), password.as_deref()) {
|
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
|
run_git_authenticated(&repo, push_args, u, p)?;
|
|
}
|
|
_ => {
|
|
run_git(&repo, push_args)?;
|
|
}
|
|
}
|
|
status_for_repo(&repo)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not push: {err}"))?
|
|
}
|
|
|
|
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
|
|
|
const CRED_SERVICE: &str = "tauri_git_lite";
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StoredCredential {
|
|
pub username: String,
|
|
pub password: String,
|
|
}
|
|
|
|
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
|
let key = key.trim();
|
|
if key.is_empty() {
|
|
return Err("No key provided for the credentials.".to_string());
|
|
}
|
|
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
|
|
}
|
|
|
|
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
|
/// current branch, falling back to `origin`, then the first configured remote).
|
|
#[tauri::command(async)]
|
|
pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string());
|
|
|
|
if let Some(url) = remote_url_for(&repo, &remote) {
|
|
return Ok(Some(url));
|
|
}
|
|
// origin missing → try the first configured remote
|
|
if let Some(first) = first_remote_name(&repo) {
|
|
if first != remote {
|
|
if let Some(url) = remote_url_for(&repo, &first) {
|
|
return Ok(Some(url));
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
|
let out = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["remote", "get-url", remote])
|
|
.output()
|
|
.ok()?;
|
|
if !out.status.success() {
|
|
return None;
|
|
}
|
|
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
if url.is_empty() { None } else { Some(url) }
|
|
}
|
|
|
|
fn remote_push_url_for(repo: &Path, remote: &str) -> Option<String> {
|
|
let out = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["remote", "get-url", "--push", remote])
|
|
.output()
|
|
.ok()?;
|
|
if !out.status.success() {
|
|
return None;
|
|
}
|
|
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
if url.is_empty() { None } else { Some(url) }
|
|
}
|
|
|
|
fn validate_remote_url(url: &str) -> Result<String, String> {
|
|
let url = url.trim();
|
|
if url.is_empty() || url.starts_with('-') {
|
|
return Err("Remote URL must not be empty.".to_string());
|
|
}
|
|
Ok(url.to_string())
|
|
}
|
|
|
|
fn validate_remote_name(repo: &Path, name: &str, must_exist: bool) -> Result<String, String> {
|
|
let name = name.trim();
|
|
if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) {
|
|
return Err("Invalid remote name.".to_string());
|
|
}
|
|
let exists = remote_url_for(repo, name).is_some();
|
|
if must_exist && !exists {
|
|
return Err(format!("Remote '{name}' was not found."));
|
|
}
|
|
if !must_exist && exists {
|
|
return Err(format!("Remote '{name}' already exists."));
|
|
}
|
|
Ok(name.to_string())
|
|
}
|
|
|
|
fn upstream_remote_name(repo: &Path) -> Option<String> {
|
|
let branch = current_branch_name(repo).ok()?;
|
|
let out = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["config", &format!("branch.{branch}.remote")])
|
|
.output()
|
|
.ok()?;
|
|
if !out.status.success() {
|
|
return None;
|
|
}
|
|
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
if name.is_empty() { None } else { Some(name) }
|
|
}
|
|
|
|
fn first_remote_name(repo: &Path) -> Option<String> {
|
|
let out = run_git(repo, ["remote"]).ok()?;
|
|
String::from_utf8_lossy(&out)
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|line| !line.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
fn current_branch_name(repo: &Path) -> Result<String, String> {
|
|
let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
|
let branch = String::from_utf8_lossy(&branch).trim().to_string();
|
|
if branch.is_empty() || branch == "HEAD" {
|
|
return Err("Could not determine current branch.".to_string());
|
|
}
|
|
Ok(branch)
|
|
}
|
|
|
|
fn branch_has_upstream(repo: &Path) -> bool {
|
|
git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
|
|
.output()
|
|
.map(|output| output.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
|
|
if remote_url_for(repo, "origin").is_some() {
|
|
return Ok("origin".to_string());
|
|
}
|
|
|
|
first_remote_name(repo)
|
|
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
|
|
push_args_for_repo_to(repo, None)
|
|
}
|
|
|
|
fn push_args_for_repo_to(
|
|
repo: &Path,
|
|
requested_remote: Option<&str>,
|
|
) -> Result<Vec<OsString>, String> {
|
|
if let Some(remote) = requested_remote.map(str::trim).filter(|v| !v.is_empty()) {
|
|
let remote = validate_remote_name(repo, remote, true)?;
|
|
let branch = current_branch_name(repo)?;
|
|
return Ok(vec![
|
|
OsString::from("push"),
|
|
OsString::from("--set-upstream"),
|
|
remote.into(),
|
|
branch.into(),
|
|
]);
|
|
}
|
|
if branch_has_upstream(repo) {
|
|
return Ok(vec![OsString::from("push")]);
|
|
}
|
|
|
|
let branch = current_branch_name(repo)?;
|
|
let remote = initial_push_remote_name(repo)?;
|
|
Ok(vec![
|
|
OsString::from("push"),
|
|
OsString::from("--set-upstream"),
|
|
OsString::from(remote),
|
|
OsString::from(branch),
|
|
])
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
|
|
let entry = cred_entry(&key)?;
|
|
match entry.get_password() {
|
|
Ok(json) => {
|
|
let cred = serde_json::from_str::<StoredCredential>(&json)
|
|
.map_err(|err| format!("Stored credentials unreadable: {err}"))?;
|
|
Ok(Some(cred))
|
|
}
|
|
Err(keyring::Error::NoEntry) => Ok(None),
|
|
Err(err) => Err(format!("Keychain access failed: {err}")),
|
|
}
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> {
|
|
let entry = cred_entry(&key)?;
|
|
let cred = StoredCredential { username, password };
|
|
let json = serde_json::to_string(&cred)
|
|
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
|
entry
|
|
.set_password(&json)
|
|
.map_err(|err| format!("Saving to keychain failed: {err}"))
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn cred_delete(key: String) -> Result<(), String> {
|
|
let entry = cred_entry(&key)?;
|
|
match entry.delete_credential() {
|
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
|
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn merge_branch(
|
|
path: String,
|
|
branch: String,
|
|
strategy: Option<String>,
|
|
) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let branch = branch.trim();
|
|
if branch.is_empty() {
|
|
return Err("Branch name must not be empty.".to_string());
|
|
}
|
|
|
|
let mut args = vec!["merge", "--no-edit"];
|
|
match strategy.as_deref().unwrap_or("default") {
|
|
"default" => {}
|
|
"squash" => args.push("--squash"),
|
|
"ff-only" => args.push("--ff-only"),
|
|
"no-ff" => args.push("--no-ff"),
|
|
_ => return Err("Unknown merge strategy.".to_string()),
|
|
}
|
|
args.push(branch);
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(args)
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if output.status.success() {
|
|
return status_for_repo(&repo);
|
|
}
|
|
|
|
// A merge that stops on conflicts leaves unmerged paths in the work tree.
|
|
// Surface those through the status so the UI can offer conflict resolution
|
|
// instead of treating the conflict as a hard error.
|
|
let status = status_for_repo(&repo)?;
|
|
if has_unresolved_conflicts(&status) {
|
|
return Ok(status);
|
|
}
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let details = if !stderr.trim().is_empty() {
|
|
stderr.trim()
|
|
} else if !stdout.trim().is_empty() {
|
|
stdout.trim()
|
|
} else {
|
|
"unknown error"
|
|
};
|
|
|
|
Err(format!("Merge failed: {details}"))
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not merge: {err}"))?
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn merge_continue(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if !merge_in_progress(&repo) {
|
|
return Err("No merge is in progress.".to_string());
|
|
}
|
|
if has_unresolved_conflicts(&status_for_repo(&repo)?) {
|
|
return Err("Resolve all conflicts before continuing the merge.".to_string());
|
|
}
|
|
run_git(&repo, ["commit", "--no-edit"])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn merge_abort(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if !merge_in_progress(&repo) {
|
|
return Err("No merge is in progress.".to_string());
|
|
}
|
|
run_git(&repo, ["merge", "--abort"])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn revert_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let commit = verify_commit(&repo, &commit)?;
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["revert", "--no-edit", commit.as_str()])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
if output.status.success() {
|
|
return status_for_repo(&repo);
|
|
}
|
|
let status = status_for_repo(&repo)?;
|
|
if has_unresolved_conflicts(&status) {
|
|
return Ok(status);
|
|
}
|
|
Err(format!(
|
|
"Revert failed: {}",
|
|
command_output_details(&output)
|
|
))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let branch = branch.trim();
|
|
if branch.is_empty() {
|
|
return Err("Branch name must not be empty.".to_string());
|
|
}
|
|
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["rebase", branch])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
rebase_status_or_error(&repo, output, "Rebase failed", true)
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not rebase: {err}"))?
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn list_interactive_rebase_commits(
|
|
path: String,
|
|
base: String,
|
|
) -> Result<Vec<RebaseCommit>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
interactive_rebase_commits_for_repo(&repo, &base)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn start_interactive_rebase(
|
|
path: String,
|
|
base: String,
|
|
plan: Vec<RebasePlanItem>,
|
|
) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let status = status_for_repo(&repo)?;
|
|
if !status.clean {
|
|
return Err(
|
|
"Commit or stash working tree changes before starting an interactive rebase."
|
|
.to_string(),
|
|
);
|
|
}
|
|
if status.rebase_in_progress || status.cherry_pick_in_progress {
|
|
return Err(
|
|
"Finish the current Git operation before starting an interactive rebase."
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let base_hash = verify_commit(&repo, &base)?;
|
|
let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?;
|
|
validate_rebase_plan(&available, &plan)?;
|
|
|
|
let todo = build_rebase_todo(&available, &plan)?;
|
|
let reword_queue = build_reword_queue(&available, &plan)?;
|
|
let git_dir = git_dir_for_repo(&repo)?;
|
|
cleanup_interactive_rebase_helpers(&repo);
|
|
let todo_path = git_dir.join(REBASE_TODO_FILE);
|
|
let reword_queue_path = git_dir.join(REWORD_QUEUE_FILE);
|
|
fs::write(&todo_path, todo)
|
|
.map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?;
|
|
fs::write(&reword_queue_path, reword_queue)
|
|
.map_err(|err| format!("Could not prepare reword messages: {err}"))?;
|
|
|
|
let sequence_helper_path = repo.join(sequence_helper_name());
|
|
let commit_helper_path = repo.join(commit_helper_name());
|
|
let current_exe = env::current_exe()
|
|
.map_err(|err| format!("Could not locate the Gitty executable: {err}"))?;
|
|
fs::copy(¤t_exe, &sequence_helper_path)
|
|
.and_then(|_| fs::copy(¤t_exe, &commit_helper_path))
|
|
.map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?;
|
|
|
|
let sequence_editor_command = format!("./{}", sequence_helper_name());
|
|
let commit_editor_command = format!("./{}", commit_helper_name());
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["rebase", "-i", base_hash.as_str()])
|
|
.env("GIT_SEQUENCE_EDITOR", sequence_editor_command)
|
|
.env(SEQUENCE_EDITOR_PLAN_ENV, &todo_path)
|
|
.env("GIT_EDITOR", commit_editor_command)
|
|
.env(COMMIT_EDITOR_QUEUE_ENV, &reword_queue_path)
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
|
|
|
|
let result = rebase_status_or_error(&repo, output?, "Interactive rebase failed", true);
|
|
let _ = fs::remove_file(&todo_path);
|
|
let _ = fs::remove_file(&sequence_helper_path);
|
|
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
|
|
let _ = fs::remove_file(&reword_queue_path);
|
|
let _ = fs::remove_file(&commit_helper_path);
|
|
}
|
|
result
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not run interactive rebase: {err}"))?
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if !rebase_in_progress(&repo) {
|
|
return Err("No rebase is currently in progress.".to_string());
|
|
}
|
|
|
|
let git_dir = git_dir_for_repo(&repo)?;
|
|
let queue_path = git_dir.join(REWORD_QUEUE_FILE);
|
|
let helper_path = repo.join(commit_helper_name());
|
|
let mut command = git_command();
|
|
command.arg("-C").arg(&repo).args(["rebase", "--continue"]);
|
|
if queue_path.exists() && helper_path.exists() {
|
|
command
|
|
.env("GIT_EDITOR", format!("./{}", commit_helper_name()))
|
|
.env(COMMIT_EDITOR_QUEUE_ENV, &queue_path);
|
|
} else {
|
|
command.env("GIT_EDITOR", "true");
|
|
}
|
|
let output = command
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
let result = rebase_status_or_error(&repo, output, "Rebase continue failed", false);
|
|
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
|
|
cleanup_interactive_rebase_helpers(&repo);
|
|
}
|
|
result
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if !rebase_in_progress(&repo) {
|
|
return Err("No rebase is currently in progress.".to_string());
|
|
}
|
|
|
|
run_git(&repo, ["rebase", "--abort"])?;
|
|
cleanup_interactive_rebase_helpers(&repo);
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string();
|
|
let output = run_git(
|
|
&repo,
|
|
[
|
|
"reflog",
|
|
"show",
|
|
"--date=iso-strict",
|
|
"--format=%H%x1f%h%x1f%gD%x1f%gs%x1f%an%x1f%aI%x1e",
|
|
"-n",
|
|
limit.as_str(),
|
|
],
|
|
)?;
|
|
parse_reflog(&output)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn restore_reflog_entry(
|
|
path: String,
|
|
commit: String,
|
|
branch: String,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let status = status_for_repo(&repo)?;
|
|
if !status.clean {
|
|
return Err(
|
|
"Commit or stash working tree changes before restoring from the reflog.".to_string(),
|
|
);
|
|
}
|
|
let commit_hash = verify_commit(&repo, &commit)?;
|
|
let branch = validate_new_branch_name(&repo, &branch)?;
|
|
run_git(
|
|
&repo,
|
|
["checkout", "-b", branch.as_str(), commit_hash.as_str()],
|
|
)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
fn interactive_rebase_commits_for_repo(
|
|
repo: &Path,
|
|
base: &str,
|
|
) -> Result<Vec<RebaseCommit>, String> {
|
|
let base_hash = verify_commit(repo, base)?;
|
|
|
|
let range = format!("{base_hash}..HEAD");
|
|
let merges = run_git(repo, ["rev-list", "--merges", range.as_str()])?;
|
|
if !String::from_utf8_lossy(&merges).trim().is_empty() {
|
|
return Err("Interactive rebase currently supports linear commit ranges only. Choose a base after the last merge commit.".to_string());
|
|
}
|
|
|
|
let output = run_git(
|
|
repo,
|
|
[
|
|
"log",
|
|
"--reverse",
|
|
"--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e",
|
|
range.as_str(),
|
|
],
|
|
)?;
|
|
|
|
let mut commits = Vec::new();
|
|
for raw in output.split(|byte| *byte == 0x1e) {
|
|
let record = String::from_utf8_lossy(raw);
|
|
let record = record.trim_matches(['\r', '\n', ' ']);
|
|
if record.is_empty() {
|
|
continue;
|
|
}
|
|
let fields = record.split('\x1f').collect::<Vec<_>>();
|
|
if fields.len() != 5 {
|
|
return Err("Git returned an unexpected interactive rebase record.".to_string());
|
|
}
|
|
commits.push(RebaseCommit {
|
|
hash: fields[0].to_string(),
|
|
short_hash: fields[1].to_string(),
|
|
summary: fields[2].to_string(),
|
|
author_name: fields[3].to_string(),
|
|
date: fields[4].to_string(),
|
|
});
|
|
}
|
|
Ok(commits)
|
|
}
|
|
|
|
fn validate_rebase_plan(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<(), String> {
|
|
if commits.is_empty() {
|
|
return Err("There are no commits to rebase onto the selected base.".to_string());
|
|
}
|
|
if commits.len() != plan.len() {
|
|
return Err("The rebase plan must include every commit exactly once.".to_string());
|
|
}
|
|
|
|
let expected = commits
|
|
.iter()
|
|
.map(|item| item.hash.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
let actual = plan
|
|
.iter()
|
|
.map(|item| item.hash.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
if actual.len() != plan.len() || actual != expected {
|
|
return Err(
|
|
"The rebase plan contains missing, duplicate, or unexpected commits.".to_string(),
|
|
);
|
|
}
|
|
|
|
let mut has_kept_commit = false;
|
|
for item in plan {
|
|
match item.action {
|
|
RebaseAction::Drop => {}
|
|
RebaseAction::Squash | RebaseAction::Fixup if !has_kept_commit => {
|
|
return Err("Squash and fixup need an earlier picked commit.".to_string());
|
|
}
|
|
RebaseAction::Reword => {
|
|
let message = item.message.as_deref().unwrap_or("").trim();
|
|
if message.is_empty() || message.contains(['\r', '\n']) {
|
|
return Err("Reword messages must be a single non-empty line.".to_string());
|
|
}
|
|
has_kept_commit = true;
|
|
}
|
|
_ => has_kept_commit = true,
|
|
}
|
|
}
|
|
if !has_kept_commit {
|
|
return Err("Keep at least one commit in the interactive rebase plan.".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn build_rebase_todo(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<String, String> {
|
|
let by_hash = commits
|
|
.iter()
|
|
.map(|commit| (commit.hash.as_str(), commit))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let mut todo = String::new();
|
|
for item in plan {
|
|
let commit = by_hash
|
|
.get(item.hash.as_str())
|
|
.ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?;
|
|
let summary = commit.summary.replace(['\r', '\n'], " ");
|
|
match item.action {
|
|
RebaseAction::Pick => todo.push_str(&format!("pick {} {}\n", item.hash, summary)),
|
|
RebaseAction::Reword => todo.push_str(&format!("reword {} {}\n", item.hash, summary)),
|
|
RebaseAction::Squash => todo.push_str(&format!("squash {} {}\n", item.hash, summary)),
|
|
RebaseAction::Fixup => todo.push_str(&format!("fixup {} {}\n", item.hash, summary)),
|
|
RebaseAction::Drop => todo.push_str(&format!("drop {} {}\n", item.hash, summary)),
|
|
}
|
|
}
|
|
Ok(todo)
|
|
}
|
|
|
|
fn build_reword_queue(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<String, String> {
|
|
let by_hash = commits
|
|
.iter()
|
|
.map(|commit| (commit.hash.as_str(), commit))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let mut queue = String::new();
|
|
for item in plan
|
|
.iter()
|
|
.filter(|item| item.action == RebaseAction::Reword)
|
|
{
|
|
let commit = by_hash
|
|
.get(item.hash.as_str())
|
|
.ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?;
|
|
let message = item.message.as_deref().unwrap_or("").trim();
|
|
queue.push_str(&commit.summary.replace(['\r', '\n'], " "));
|
|
queue.push('\x1f');
|
|
queue.push_str(message);
|
|
queue.push('\x1e');
|
|
}
|
|
Ok(queue)
|
|
}
|
|
|
|
fn git_dir_for_repo(repo: &Path) -> Result<PathBuf, String> {
|
|
let output = run_git(repo, ["rev-parse", "--absolute-git-dir"])?;
|
|
let path = String::from_utf8_lossy(&output).trim().to_string();
|
|
if path.is_empty() {
|
|
Err("Git could not determine its metadata directory.".to_string())
|
|
} else {
|
|
Ok(PathBuf::from(path))
|
|
}
|
|
}
|
|
|
|
fn sequence_helper_name() -> &'static str {
|
|
if cfg!(windows) {
|
|
".gitty-sequence-editor.exe"
|
|
} else {
|
|
SEQUENCE_HELPER_STEM
|
|
}
|
|
}
|
|
|
|
fn commit_helper_name() -> &'static str {
|
|
if cfg!(windows) {
|
|
".gitty-commit-editor.exe"
|
|
} else {
|
|
COMMIT_HELPER_STEM
|
|
}
|
|
}
|
|
|
|
fn cleanup_interactive_rebase_helpers(repo: &Path) {
|
|
if let Ok(git_dir) = git_dir_for_repo(repo) {
|
|
let _ = fs::remove_file(git_dir.join(REBASE_TODO_FILE));
|
|
let _ = fs::remove_file(git_dir.join(REWORD_QUEUE_FILE));
|
|
}
|
|
let _ = fs::remove_file(repo.join(sequence_helper_name()));
|
|
let _ = fs::remove_file(repo.join(commit_helper_name()));
|
|
}
|
|
|
|
fn is_interactive_rebase_helper_path(path: &str) -> bool {
|
|
matches!(
|
|
path.replace('\\', "/").rsplit('/').next(),
|
|
Some(name) if name == sequence_helper_name() || name == commit_helper_name()
|
|
)
|
|
}
|
|
|
|
fn parse_reflog(output: &[u8]) -> Result<Vec<ReflogEntry>, String> {
|
|
let mut entries = Vec::new();
|
|
for raw in output.split(|byte| *byte == 0x1e) {
|
|
let record = String::from_utf8_lossy(raw);
|
|
let record = record.trim_matches(['\r', '\n', ' ']);
|
|
if record.is_empty() {
|
|
continue;
|
|
}
|
|
let fields = record.split('\x1f').collect::<Vec<_>>();
|
|
if fields.len() != 6 {
|
|
return Err("Git returned an unexpected reflog record.".to_string());
|
|
}
|
|
entries.push(ReflogEntry {
|
|
hash: fields[0].to_string(),
|
|
short_hash: fields[1].to_string(),
|
|
selector: fields[2].to_string(),
|
|
action: fields[3].to_string(),
|
|
author_name: fields[4].to_string(),
|
|
date: fields[5].to_string(),
|
|
});
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
fn rebase_status_or_error(
|
|
repo: &Path,
|
|
output: Output,
|
|
context: &str,
|
|
ok_if_rebase_in_progress: bool,
|
|
) -> Result<GitStatus, String> {
|
|
if output.status.success() {
|
|
return status_for_repo(repo);
|
|
}
|
|
|
|
let status = status_for_repo(repo)?;
|
|
if has_unresolved_conflicts(&status) || (ok_if_rebase_in_progress && status.rebase_in_progress)
|
|
{
|
|
return Ok(status);
|
|
}
|
|
|
|
Err(format!("{context}: {}", command_output_details(&output)))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let commit_hash = verify_commit(&repo, &commit)?;
|
|
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["cherry-pick", commit_hash.as_str()])
|
|
.env("GIT_EDITOR", "true")
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not cherry-pick: {err}"))?
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if !cherry_pick_in_progress(&repo) {
|
|
return Err("No cherry-pick is currently in progress.".to_string());
|
|
}
|
|
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["cherry-pick", "--continue"])
|
|
.env("GIT_EDITOR", "true")
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if !cherry_pick_in_progress(&repo) {
|
|
return Err("No cherry-pick is currently in progress.".to_string());
|
|
}
|
|
|
|
run_git(&repo, ["cherry-pick", "--abort"])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
fn cherry_pick_status_or_error(
|
|
repo: &Path,
|
|
output: Output,
|
|
context: &str,
|
|
) -> Result<GitStatus, String> {
|
|
if output.status.success() {
|
|
return status_for_repo(repo);
|
|
}
|
|
|
|
let status = status_for_repo(repo)?;
|
|
if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress {
|
|
return Ok(status);
|
|
}
|
|
|
|
Err(format!("{context}: {}", command_output_details(&output)))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_commits(
|
|
path: String,
|
|
limit: Option<u32>,
|
|
skip: Option<u32>,
|
|
) -> Result<Vec<GitCommit>, String> {
|
|
run_git_task("Could not load commit history", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
commit_page_for_repo(&repo, limit, skip)
|
|
})
|
|
.await
|
|
}
|
|
|
|
const COMMIT_NOTES_REF: &str = "refs/notes/commits";
|
|
const COMMIT_NOTES_SYNC_REF: &str = "refs/gitlite/notes-sync";
|
|
const MAX_COMMIT_NOTE_BYTES: usize = 256 * 1024;
|
|
|
|
#[tauri::command]
|
|
pub async fn get_commit_note(path: String, commit: String) -> Result<Option<String>, String> {
|
|
run_git_task("Could not load commit note", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
commit_note_for_repo(&repo, &commit)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn set_commit_note(path: String, commit: String, note: String) -> Result<(), String> {
|
|
run_git_task("Could not save commit note", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
set_commit_note_for_repo(&repo, &commit, ¬e)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_commit_note(path: String, commit: String) -> Result<(), String> {
|
|
run_git_task("Could not delete commit note", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
delete_commit_note_for_repo(&repo, &commit)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn fetch_commit_notes(
|
|
path: String,
|
|
remote: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
) -> Result<(), String> {
|
|
run_git_task("Could not fetch commit notes", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
fetch_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn push_commit_notes(
|
|
path: String,
|
|
remote: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
) -> Result<(), String> {
|
|
run_git_task("Could not push commit notes", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
push_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn commit_note_for_repo(repo: &Path, commit: &str) -> Result<Option<String>, String> {
|
|
let commit = verify_commit(repo, commit)?;
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["notes", "--ref", COMMIT_NOTES_REF, "list", commit.as_str()])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if output.status.code() == Some(1) {
|
|
return Ok(None);
|
|
}
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"Could not inspect commit note: {}",
|
|
command_output_details(&output)
|
|
));
|
|
}
|
|
|
|
let note_object = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if note_object.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let note = run_git(repo, ["cat-file", "blob", note_object.as_str()])?;
|
|
let mut note =
|
|
String::from_utf8(note).map_err(|_| "Commit note is not valid UTF-8 text.".to_string())?;
|
|
if note.ends_with('\n') {
|
|
note.pop();
|
|
if note.ends_with('\r') {
|
|
note.pop();
|
|
}
|
|
}
|
|
Ok(Some(note))
|
|
}
|
|
|
|
fn set_commit_note_for_repo(repo: &Path, commit: &str, note: &str) -> Result<(), String> {
|
|
let commit = verify_commit(repo, commit)?;
|
|
if note.trim().is_empty() {
|
|
return Err("Commit note must not be empty. Use Delete to remove it.".to_string());
|
|
}
|
|
if note.len() > MAX_COMMIT_NOTE_BYTES {
|
|
return Err(format!(
|
|
"Commit note is too large (maximum {} KiB).",
|
|
MAX_COMMIT_NOTE_BYTES / 1024
|
|
));
|
|
}
|
|
|
|
run_git_with_stdin(
|
|
repo,
|
|
[
|
|
"notes",
|
|
"--ref",
|
|
COMMIT_NOTES_REF,
|
|
"add",
|
|
"-f",
|
|
"-F",
|
|
"-",
|
|
"--",
|
|
commit.as_str(),
|
|
],
|
|
note.as_bytes(),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn delete_commit_note_for_repo(repo: &Path, commit: &str) -> Result<(), String> {
|
|
let commit = verify_commit(repo, commit)?;
|
|
run_git(
|
|
repo,
|
|
[
|
|
"notes",
|
|
"--ref",
|
|
COMMIT_NOTES_REF,
|
|
"remove",
|
|
"--ignore-missing",
|
|
"--",
|
|
commit.as_str(),
|
|
],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn fetch_commit_notes_for_repo(
|
|
repo: &Path,
|
|
remote: &str,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
) -> Result<(), String> {
|
|
let remote = validate_remote_name(repo, remote, true)?;
|
|
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
|
let refspec = format!("+{COMMIT_NOTES_REF}:{COMMIT_NOTES_SYNC_REF}");
|
|
let fetch_args = ["fetch", remote.as_str(), refspec.as_str()];
|
|
let fetched = match (username, password) {
|
|
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
|
|
run_git_authenticated(repo, fetch_args, user, pass)
|
|
}
|
|
_ => run_git(repo, fetch_args),
|
|
};
|
|
|
|
if let Err(error) = fetched {
|
|
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
|
return Err(
|
|
if error.to_lowercase().contains("couldn't find remote ref") {
|
|
format!("Remote '{remote}' does not contain commit notes yet.")
|
|
} else {
|
|
error
|
|
},
|
|
);
|
|
}
|
|
|
|
let merge_result = (|| -> Result<(), String> {
|
|
if ref_exists(repo, COMMIT_NOTES_REF)? {
|
|
run_git(
|
|
repo,
|
|
[
|
|
"notes",
|
|
"--ref",
|
|
COMMIT_NOTES_REF,
|
|
"merge",
|
|
"-s",
|
|
"cat_sort_uniq",
|
|
COMMIT_NOTES_SYNC_REF,
|
|
],
|
|
)?;
|
|
} else {
|
|
let remote_notes_hash = run_git(repo, ["rev-parse", COMMIT_NOTES_SYNC_REF])?;
|
|
let remote_notes_hash = String::from_utf8_lossy(&remote_notes_hash)
|
|
.trim()
|
|
.to_string();
|
|
run_git(
|
|
repo,
|
|
["update-ref", COMMIT_NOTES_REF, remote_notes_hash.as_str()],
|
|
)?;
|
|
}
|
|
Ok(())
|
|
})();
|
|
|
|
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
|
merge_result
|
|
}
|
|
|
|
fn push_commit_notes_for_repo(
|
|
repo: &Path,
|
|
remote: &str,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
) -> Result<(), String> {
|
|
let remote = validate_remote_name(repo, remote, true)?;
|
|
if !ref_exists(repo, COMMIT_NOTES_REF)? {
|
|
return Err("There are no local commit notes to push.".to_string());
|
|
}
|
|
let refspec = format!("{COMMIT_NOTES_REF}:{COMMIT_NOTES_REF}");
|
|
let push_args = ["push", remote.as_str(), refspec.as_str()];
|
|
match (username, password) {
|
|
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
|
|
run_git_authenticated(repo, push_args, user, pass)?;
|
|
}
|
|
_ => {
|
|
run_git(repo, push_args)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
|
let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
|
|
commit_page_for_repo(repo, Some(bounded_limit), None)
|
|
}
|
|
|
|
fn commit_page_for_repo(
|
|
repo: &Path,
|
|
limit: Option<u32>,
|
|
skip: Option<u32>,
|
|
) -> Result<Vec<GitCommit>, String> {
|
|
if verify_commit(repo, "HEAD").is_err() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let limit = limit.unwrap_or(100).clamp(1, 5_000).to_string();
|
|
let skip = skip.unwrap_or(0).min(10_000_000).to_string();
|
|
// Fetch the per-commit changed files inline via `--name-status` in a single
|
|
// `git log` process, instead of spawning one `git diff-tree` per commit
|
|
// (which was ~100 extra processes and the main cost of opening a repo).
|
|
let output = run_git(
|
|
repo,
|
|
[
|
|
"log",
|
|
"--all",
|
|
"--topo-order",
|
|
"--decorate=short",
|
|
"--name-status",
|
|
"-M",
|
|
"-z",
|
|
"--root",
|
|
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
|
|
"--skip",
|
|
skip.as_str(),
|
|
"-n",
|
|
limit.as_str(),
|
|
],
|
|
)?;
|
|
|
|
parse_commit_log_inline(&output)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
|
|
run_git_task("Could not load repository files", move || {
|
|
let repo = resolve_repo(&path)?;
|
|
repository_files(&repo)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_file_history(
|
|
path: String,
|
|
file: String,
|
|
limit: Option<u32>,
|
|
request_id: Option<String>,
|
|
state: tauri::State<'_, SearchCancellationState>,
|
|
) -> Result<Vec<GitCommit>, String> {
|
|
let state = state.inner().clone();
|
|
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let repo = resolve_repo(&path)?;
|
|
let request_id = request_id
|
|
.map(|id| id.trim().to_string())
|
|
.filter(|id| !id.is_empty());
|
|
let cancellation = request_id.as_ref().map(|request_id| SearchCancellation {
|
|
state: state.clone(),
|
|
search_id: request_id.clone(),
|
|
});
|
|
|
|
let result = list_file_history_core(&repo, file, limit, cancellation.as_ref());
|
|
|
|
if let Some(request_id) = request_id.as_deref() {
|
|
let _ = state.clear(request_id);
|
|
}
|
|
|
|
result
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not load file history: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn cancel_file_history(
|
|
request_id: String,
|
|
state: tauri::State<'_, SearchCancellationState>,
|
|
) -> Result<(), String> {
|
|
let request_id = request_id.trim();
|
|
if request_id.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
state.cancel(request_id)
|
|
}
|
|
|
|
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
|
|
|
|
#[tauri::command(async)]
|
|
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
|
|
if verify_commit(&repo, "HEAD").is_err() {
|
|
return Err("Repository has no commits yet.".to_string());
|
|
}
|
|
|
|
let args = vec![
|
|
OsString::from("blame"),
|
|
OsString::from("--line-porcelain"),
|
|
OsString::from("--"),
|
|
OsString::from(file.clone()),
|
|
];
|
|
let output =
|
|
run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?;
|
|
|
|
Ok(GitBlameResult {
|
|
path: file,
|
|
lines: parse_blame_porcelain(&output),
|
|
})
|
|
}
|
|
|
|
fn parse_blame_porcelain(output: &[u8]) -> Vec<GitBlameLine> {
|
|
#[derive(Default, Clone)]
|
|
struct BlameMeta {
|
|
author_name: String,
|
|
author_email: String,
|
|
author_time: i64,
|
|
summary: String,
|
|
}
|
|
|
|
let text = String::from_utf8_lossy(output);
|
|
let mut lines_out: Vec<GitBlameLine> = Vec::new();
|
|
let mut commit_meta: BTreeMap<String, BlameMeta> = BTreeMap::new();
|
|
let mut current_hash = String::new();
|
|
let mut current_final_line: u32 = 0;
|
|
|
|
for line in text.split('\n') {
|
|
if let Some(content) = line.strip_prefix('\t') {
|
|
let meta = commit_meta.get(¤t_hash).cloned().unwrap_or_default();
|
|
lines_out.push(GitBlameLine {
|
|
line_number: current_final_line,
|
|
content: content.to_string(),
|
|
commit_hash: current_hash.clone(),
|
|
short_hash: short_hash(¤t_hash),
|
|
author_name: meta.author_name,
|
|
author_email: meta.author_email,
|
|
author_time: meta.author_time,
|
|
summary: meta.summary,
|
|
is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let mut parts = line.splitn(2, ' ');
|
|
let head = parts.next().unwrap_or("");
|
|
let tail = parts.next().unwrap_or("");
|
|
|
|
if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
if let Some(final_line) = tail.split_whitespace().nth(1) {
|
|
current_final_line = final_line.parse().unwrap_or(current_final_line);
|
|
}
|
|
current_hash = head.to_string();
|
|
commit_meta.entry(current_hash.clone()).or_default();
|
|
continue;
|
|
}
|
|
|
|
match head {
|
|
"author" => {
|
|
commit_meta
|
|
.entry(current_hash.clone())
|
|
.or_default()
|
|
.author_name = tail.to_string()
|
|
}
|
|
"author-mail" => {
|
|
let email = tail.trim_matches(|c| c == '<' || c == '>').to_string();
|
|
commit_meta
|
|
.entry(current_hash.clone())
|
|
.or_default()
|
|
.author_email = email;
|
|
}
|
|
"author-time" => {
|
|
commit_meta
|
|
.entry(current_hash.clone())
|
|
.or_default()
|
|
.author_time = tail.parse().unwrap_or(0);
|
|
}
|
|
"summary" => {
|
|
commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string()
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
lines_out
|
|
}
|
|
|
|
fn list_file_history_core(
|
|
repo: &Path,
|
|
file: String,
|
|
limit: Option<u32>,
|
|
cancellation: Option<&SearchCancellation>,
|
|
) -> Result<Vec<GitCommit>, String> {
|
|
check_search_cancelled(cancellation)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
|
|
if verify_commit(repo, "HEAD").is_err() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
|
|
let mut args = vec![
|
|
OsString::from("log"),
|
|
OsString::from("--decorate=short"),
|
|
OsString::from("--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e"),
|
|
OsString::from("-n"),
|
|
OsString::from(limit),
|
|
];
|
|
if !is_repository_folder_path(repo, &file)? {
|
|
args.push(OsString::from("--follow"));
|
|
}
|
|
args.extend([OsString::from("--"), OsString::from(file)]);
|
|
let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?;
|
|
check_search_cancelled(cancellation)?;
|
|
|
|
parse_commit_log(repo, &output)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn search_code_introductions(
|
|
path: String,
|
|
query: String,
|
|
case_sensitive: Option<bool>,
|
|
limit: Option<u32>,
|
|
search_id: Option<String>,
|
|
state: tauri::State<'_, SearchCancellationState>,
|
|
) -> Result<Vec<GitSearchHit>, String> {
|
|
let state = state.inner().clone();
|
|
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let repo = resolve_repo(&path)?;
|
|
let query = normalize_newlines(&query);
|
|
let query = query.trim_matches('\n').to_string();
|
|
if query.trim().is_empty() {
|
|
return Err("Search text must not be empty.".to_string());
|
|
}
|
|
|
|
if verify_commit(&repo, "HEAD").is_err() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let case_sensitive = case_sensitive.unwrap_or(false);
|
|
let limit = limit.unwrap_or(250).clamp(1, 1000) as usize;
|
|
let search_id = search_id
|
|
.map(|id| id.trim().to_string())
|
|
.filter(|id| !id.is_empty());
|
|
|
|
let cancellation = search_id.as_ref().map(|search_id| SearchCancellation {
|
|
state: state.clone(),
|
|
search_id: search_id.clone(),
|
|
});
|
|
|
|
let result = search_code_introductions_core(
|
|
&repo,
|
|
query,
|
|
case_sensitive,
|
|
limit,
|
|
cancellation.as_ref(),
|
|
);
|
|
|
|
if let Some(search_id) = search_id.as_deref() {
|
|
let _ = state.clear(search_id);
|
|
}
|
|
|
|
result
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not complete search task: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn cancel_code_search(
|
|
search_id: String,
|
|
state: tauri::State<'_, SearchCancellationState>,
|
|
) -> Result<(), String> {
|
|
let search_id = search_id.trim();
|
|
if search_id.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
state.cancel(search_id)
|
|
}
|
|
|
|
fn search_code_introductions_core(
|
|
repo: &Path,
|
|
query: String,
|
|
case_sensitive: bool,
|
|
limit: usize,
|
|
cancellation: Option<&SearchCancellation>,
|
|
) -> Result<Vec<GitSearchHit>, String> {
|
|
check_search_cancelled(cancellation)?;
|
|
let candidates = search_candidate_commits(&repo, &query, case_sensitive, cancellation)?;
|
|
let mut hits = Vec::new();
|
|
|
|
for commit in candidates {
|
|
check_search_cancelled(cancellation)?;
|
|
if hits.len() >= limit {
|
|
break;
|
|
}
|
|
|
|
let files = commit_files(&repo, &commit)?;
|
|
if files.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let parents = commit_parents(&repo, &commit)?;
|
|
let mut metadata: Option<GitSearchCommitMetadata> = None;
|
|
|
|
for file in files {
|
|
check_search_cancelled(cancellation)?;
|
|
if hits.len() >= limit {
|
|
break;
|
|
}
|
|
|
|
if matches!(file.status, FileStatusKind::Deleted) {
|
|
continue;
|
|
}
|
|
|
|
let Some(after_content) = read_text_blob(&repo, &commit, &file.path)? else {
|
|
continue;
|
|
};
|
|
let after_count = count_matches(&after_content, &query, case_sensitive);
|
|
if after_count == 0 {
|
|
continue;
|
|
}
|
|
|
|
let before_count = max_parent_match_count(
|
|
&repo,
|
|
&parents,
|
|
file.old_path.as_deref().unwrap_or(&file.path),
|
|
&query,
|
|
case_sensitive,
|
|
)?;
|
|
|
|
if after_count <= before_count {
|
|
continue;
|
|
}
|
|
|
|
let match_line = first_added_match_line(
|
|
&repo,
|
|
parents.first().map(String::as_str),
|
|
&commit,
|
|
&file.path,
|
|
&query,
|
|
case_sensitive,
|
|
cancellation,
|
|
)?
|
|
.or_else(|| first_match_line(&after_content, &query, case_sensitive));
|
|
|
|
let Some((line_number, line)) = match_line else {
|
|
continue;
|
|
};
|
|
let info = metadata
|
|
.get_or_insert_with(|| {
|
|
commit_search_metadata(&repo, &commit).unwrap_or_else(|_| {
|
|
GitSearchCommitMetadata {
|
|
commit_hash: commit.clone(),
|
|
short_hash: short_hash(&commit),
|
|
author_name: String::new(),
|
|
author_email: String::new(),
|
|
date: String::new(),
|
|
summary: String::new(),
|
|
}
|
|
})
|
|
})
|
|
.clone();
|
|
|
|
hits.push(GitSearchHit {
|
|
commit_hash: info.commit_hash,
|
|
short_hash: info.short_hash,
|
|
summary: info.summary,
|
|
author_name: info.author_name,
|
|
author_email: info.author_email,
|
|
date: info.date,
|
|
file: file.path,
|
|
old_file: file.old_path,
|
|
line_number: Some(line_number),
|
|
line,
|
|
matches_added: after_count.saturating_sub(before_count) as u32,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(hits)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let commit_hash = verify_commit(&repo, &commit)?;
|
|
|
|
// Non-destructive: bring the working tree back to how it looked at `commit` without
|
|
// moving the branch pointer (unlike `git reset --hard`, which would rewrite history and
|
|
// hide any newer commits from the log). The result lands as ordinary unstaged changes
|
|
// that the user reviews in the status panel and stages/commits or discards explicitly.
|
|
run_git(
|
|
&repo,
|
|
[
|
|
"restore",
|
|
"--source",
|
|
commit_hash.as_str(),
|
|
"--worktree",
|
|
"--",
|
|
".",
|
|
],
|
|
)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn restore_file_from_commit(
|
|
path: String,
|
|
commit: String,
|
|
file: String,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
let commit_hash = verify_commit(&repo, &commit)?;
|
|
|
|
run_git_with_paths(
|
|
&repo,
|
|
&["restore", "--source", commit_hash.as_str(), "--worktree"],
|
|
&[file],
|
|
)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn compare_commits(
|
|
path: String,
|
|
from: String,
|
|
to: String,
|
|
) -> Result<GitCommitComparison, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let from_hash = verify_commit(&repo, &from)?;
|
|
let to_hash = verify_commit(&repo, &to)?;
|
|
|
|
let name_status = run_git(
|
|
&repo,
|
|
[
|
|
"diff",
|
|
"--name-status",
|
|
"-M",
|
|
"-z",
|
|
from_hash.as_str(),
|
|
to_hash.as_str(),
|
|
],
|
|
)?;
|
|
let numstat = run_git(
|
|
&repo,
|
|
[
|
|
"diff",
|
|
"--numstat",
|
|
"-M",
|
|
"-z",
|
|
from_hash.as_str(),
|
|
to_hash.as_str(),
|
|
],
|
|
)?;
|
|
let patch_output = run_git(
|
|
&repo,
|
|
[
|
|
"diff",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
"-M",
|
|
FULL_FILE_DIFF_CONTEXT,
|
|
from_hash.as_str(),
|
|
to_hash.as_str(),
|
|
],
|
|
)?;
|
|
|
|
let files = parse_diff_files(&name_status, &numstat)?;
|
|
let patch = String::from_utf8_lossy(&patch_output).to_string();
|
|
|
|
Ok(GitCommitComparison {
|
|
from_short: short_hash(&from_hash),
|
|
to_short: short_hash(&to_hash),
|
|
from_hash,
|
|
to_hash,
|
|
files,
|
|
patch,
|
|
})
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn diff_file_against_working_tree(
|
|
path: String,
|
|
commit: String,
|
|
file: String,
|
|
) -> Result<GitCommitComparison, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
let commit_hash = verify_commit(&repo, &commit)?;
|
|
|
|
let name_status = run_git_with_paths(
|
|
&repo,
|
|
&["diff", "--name-status", "-M", "-z", commit_hash.as_str()],
|
|
std::slice::from_ref(&file),
|
|
)?;
|
|
let numstat = run_git_with_paths(
|
|
&repo,
|
|
&["diff", "--numstat", "-M", "-z", commit_hash.as_str()],
|
|
std::slice::from_ref(&file),
|
|
)?;
|
|
let patch_output = run_git_with_paths(
|
|
&repo,
|
|
&[
|
|
"diff",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
"-M",
|
|
FULL_FILE_DIFF_CONTEXT,
|
|
commit_hash.as_str(),
|
|
],
|
|
std::slice::from_ref(&file),
|
|
)?;
|
|
|
|
let files = parse_diff_files(&name_status, &numstat)?;
|
|
let patch = String::from_utf8_lossy(&patch_output).to_string();
|
|
|
|
Ok(GitCommitComparison {
|
|
from_short: short_hash(&commit_hash),
|
|
to_short: "working tree".to_string(),
|
|
from_hash: commit_hash,
|
|
to_hash: String::new(),
|
|
files,
|
|
patch,
|
|
})
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn compare_file_to_head(
|
|
path: String,
|
|
commit: String,
|
|
file: String,
|
|
) -> Result<GitCommitComparison, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
let commit_hash = verify_commit(&repo, &commit)?;
|
|
let head_hash = verify_commit(&repo, "HEAD")?;
|
|
|
|
let name_status = run_git_with_paths(
|
|
&repo,
|
|
&[
|
|
"diff",
|
|
"--name-status",
|
|
"-M",
|
|
"-z",
|
|
commit_hash.as_str(),
|
|
head_hash.as_str(),
|
|
],
|
|
std::slice::from_ref(&file),
|
|
)?;
|
|
let numstat = run_git_with_paths(
|
|
&repo,
|
|
&[
|
|
"diff",
|
|
"--numstat",
|
|
"-M",
|
|
"-z",
|
|
commit_hash.as_str(),
|
|
head_hash.as_str(),
|
|
],
|
|
std::slice::from_ref(&file),
|
|
)?;
|
|
let patch_output = run_git_with_paths(
|
|
&repo,
|
|
&[
|
|
"diff",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
"-M",
|
|
FULL_FILE_DIFF_CONTEXT,
|
|
commit_hash.as_str(),
|
|
head_hash.as_str(),
|
|
],
|
|
std::slice::from_ref(&file),
|
|
)?;
|
|
|
|
let files = parse_diff_files(&name_status, &numstat)?;
|
|
let patch = String::from_utf8_lossy(&patch_output).to_string();
|
|
|
|
Ok(GitCommitComparison {
|
|
from_short: short_hash(&commit_hash),
|
|
to_short: "HEAD".to_string(),
|
|
from_hash: commit_hash,
|
|
to_hash: head_hash,
|
|
files,
|
|
patch,
|
|
})
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn compare_file_to_parent(
|
|
path: String,
|
|
commit: String,
|
|
file: String,
|
|
old_file: Option<String>,
|
|
) -> Result<GitCommitComparison, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let mut pathspecs = Vec::new();
|
|
if let Some(old_file) = old_file.filter(|value| !value.trim().is_empty() && value != &file) {
|
|
pathspecs.push(old_file);
|
|
}
|
|
pathspecs.push(file);
|
|
validate_files(&pathspecs)?;
|
|
|
|
let commit_hash = verify_commit(&repo, &commit)?;
|
|
let parents = commit_parents(&repo, &commit_hash)?;
|
|
let (from_hash, from_short) = if let Some(parent) = parents.first() {
|
|
(parent.clone(), short_hash(parent))
|
|
} else {
|
|
(EMPTY_TREE_HASH.to_string(), "empty tree".to_string())
|
|
};
|
|
|
|
let name_status = run_git_with_paths(
|
|
&repo,
|
|
&[
|
|
"diff",
|
|
"--name-status",
|
|
"-M",
|
|
"-z",
|
|
from_hash.as_str(),
|
|
commit_hash.as_str(),
|
|
],
|
|
&pathspecs,
|
|
)?;
|
|
let numstat = run_git_with_paths(
|
|
&repo,
|
|
&[
|
|
"diff",
|
|
"--numstat",
|
|
"-M",
|
|
"-z",
|
|
from_hash.as_str(),
|
|
commit_hash.as_str(),
|
|
],
|
|
&pathspecs,
|
|
)?;
|
|
let patch_output = run_git_with_paths(
|
|
&repo,
|
|
&[
|
|
"diff",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
"-M",
|
|
FULL_FILE_DIFF_CONTEXT,
|
|
from_hash.as_str(),
|
|
commit_hash.as_str(),
|
|
],
|
|
&pathspecs,
|
|
)?;
|
|
|
|
let files = parse_diff_files(&name_status, &numstat)?;
|
|
let patch = String::from_utf8_lossy(&patch_output).to_string();
|
|
|
|
Ok(GitCommitComparison {
|
|
from_short,
|
|
to_short: short_hash(&commit_hash),
|
|
from_hash,
|
|
to_hash: commit_hash,
|
|
files,
|
|
patch,
|
|
})
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
|
|
let bytes = std::fs::read(repo.join(&file))
|
|
.map_err(|err| format!("Could not read conflict file: {err}"))?;
|
|
let binary = is_binary_bytes(&bytes);
|
|
|
|
// For binary files we cannot offer a text merge, so we only report the side
|
|
// sizes; the UI lets the user pick which side to keep wholesale.
|
|
if binary {
|
|
return Ok(ConflictFile {
|
|
path: file.clone(),
|
|
content: String::new(),
|
|
ours: None,
|
|
theirs: None,
|
|
base: None,
|
|
binary: true,
|
|
ours_size: index_stage_size(&repo, 2, &file),
|
|
theirs_size: index_stage_size(&repo, 3, &file),
|
|
});
|
|
}
|
|
|
|
Ok(ConflictFile {
|
|
base: read_index_stage(&repo, 1, &file),
|
|
ours: read_index_stage(&repo, 2, &file),
|
|
theirs: read_index_stage(&repo, 3, &file),
|
|
binary: false,
|
|
ours_size: index_stage_size(&repo, 2, &file),
|
|
theirs_size: index_stage_size(&repo, 3, &file),
|
|
path: file,
|
|
content: String::from_utf8_lossy(&bytes).to_string(),
|
|
})
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn resolve_conflict_side(
|
|
path: String,
|
|
file: String,
|
|
side: String,
|
|
) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
|
|
let flag = match side.as_str() {
|
|
"ours" => "--ours",
|
|
"theirs" => "--theirs",
|
|
_ => return Err("Invalid side. Allowed values are 'ours' or 'theirs'.".to_string()),
|
|
};
|
|
|
|
run_git_with_paths(&repo, &["checkout", flag], std::slice::from_ref(&file))?;
|
|
run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
fn is_binary_bytes(bytes: &[u8]) -> bool {
|
|
// Git's own heuristic: a NUL byte within the first 8000 bytes marks the
|
|
// blob as binary.
|
|
bytes.iter().take(8000).any(|byte| *byte == 0)
|
|
}
|
|
|
|
fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<u64> {
|
|
let spec = format!(":{stage}:{file}");
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["cat-file", "-s", spec.as_str()])
|
|
.output()
|
|
.ok()?;
|
|
|
|
if output.status.success() {
|
|
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[tauri::command(async)]
|
|
pub fn resolve_conflict(path: String, file: String, content: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(std::slice::from_ref(&file))?;
|
|
|
|
let target = repo.join(&file);
|
|
if let Some(parent) = target.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.map_err(|err| format!("Could not create directory: {err}"))?;
|
|
}
|
|
std::fs::write(&target, content)
|
|
.map_err(|err| format!("Could not write conflict file: {err}"))?;
|
|
|
|
run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
fn read_index_stage(repo: &Path, stage: u8, file: &str) -> Option<String> {
|
|
let spec = format!(":{stage}:{file}");
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["show", spec.as_str()])
|
|
.output()
|
|
.ok()?;
|
|
|
|
if output.status.success() {
|
|
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn short_hash(hash: &str) -> String {
|
|
hash.chars().take(7).collect()
|
|
}
|
|
|
|
fn resolve_repo(path: &str) -> Result<PathBuf, String> {
|
|
if path.trim().is_empty() {
|
|
return Err("Repository path must not be empty.".to_string());
|
|
}
|
|
|
|
let input = PathBuf::from(path);
|
|
let output = run_git_at(
|
|
&input,
|
|
["rev-parse", "--show-toplevel"],
|
|
"Not a Git repository or unreachable",
|
|
)?;
|
|
let top_level = String::from_utf8_lossy(&output).trim().to_string();
|
|
|
|
if top_level.is_empty() {
|
|
return Err("Git could not determine the repository root path.".to_string());
|
|
}
|
|
|
|
Ok(PathBuf::from(top_level))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
|
let native_path = path.to_string_lossy().replace('/', "\\");
|
|
let mut command = Command::new("explorer.exe");
|
|
command.arg(native_path);
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
command
|
|
.spawn()
|
|
.map_err(|err| format!("Could not launch Explorer: {err}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
|
Command::new("open")
|
|
.arg(path)
|
|
.spawn()
|
|
.map_err(|err| format!("Could not launch Finder: {err}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
|
Command::new("xdg-open")
|
|
.arg(path)
|
|
.spawn()
|
|
.map_err(|err| format!("Could not launch file manager: {err}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_repo_child_path(repo: &Path, child: &str) -> Result<PathBuf, String> {
|
|
let child_path = Path::new(child);
|
|
if child_path.is_absolute()
|
|
|| child_path.components().any(|component| {
|
|
matches!(
|
|
component,
|
|
std::path::Component::ParentDir
|
|
| std::path::Component::RootDir
|
|
| std::path::Component::Prefix(_)
|
|
)
|
|
})
|
|
{
|
|
return Err("File path must stay within the repository.".to_string());
|
|
}
|
|
|
|
let candidate = repo.join(child_path);
|
|
let repo = repo
|
|
.canonicalize()
|
|
.map_err(|err| format!("Could not resolve repository path: {err}"))?;
|
|
let candidate = candidate
|
|
.canonicalize()
|
|
.map_err(|err| format!("Could not resolve file path: {err}"))?;
|
|
|
|
if !candidate.starts_with(&repo) {
|
|
return Err("File path lies outside the repository.".to_string());
|
|
}
|
|
|
|
Ok(candidate)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
|
|
let native_path = path.to_string_lossy().replace('/', "\\");
|
|
let mut command = Command::new("explorer.exe");
|
|
command.arg(format!("/select,{native_path}"));
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
command
|
|
.spawn()
|
|
.map_err(|err| format!("Could not launch Explorer: {err}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
|
|
Command::new("open")
|
|
.arg("-R")
|
|
.arg(path)
|
|
.spawn()
|
|
.map_err(|err| format!("Could not launch Finder: {err}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
|
|
// No universal "select this file" flag across Linux file managers; open its folder instead.
|
|
let target = path.parent().unwrap_or(path);
|
|
Command::new("xdg-open")
|
|
.arg(target)
|
|
.spawn()
|
|
.map_err(|err| format!("Could not launch file manager: {err}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
|
|
let commit = commit.trim();
|
|
if commit.is_empty() {
|
|
return Err("Commit must not be empty.".to_string());
|
|
}
|
|
|
|
let rev = format!("{commit}^{{commit}}");
|
|
let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()])
|
|
.map_err(|err| format!("Could not find commit: {err}"))?;
|
|
let hash = String::from_utf8_lossy(&output).trim().to_string();
|
|
|
|
if hash.is_empty() {
|
|
return Err("Commit could not be found.".to_string());
|
|
}
|
|
|
|
Ok(hash)
|
|
}
|
|
|
|
fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|
let output = run_git(
|
|
repo,
|
|
[
|
|
"status",
|
|
"--porcelain=v1",
|
|
"-b",
|
|
"-z",
|
|
"--untracked-files=all",
|
|
],
|
|
)?;
|
|
let (branch, mut files) = parse_status_output(&output)?;
|
|
detect_worktree_renames(repo, &mut files);
|
|
files.retain(|file| !is_interactive_rebase_helper_path(&file.path));
|
|
|
|
Ok(GitStatus {
|
|
repo_path: repo.to_string_lossy().to_string(),
|
|
current_branch: branch.current_branch,
|
|
upstream: branch.upstream,
|
|
ahead: branch.ahead,
|
|
behind: branch.behind,
|
|
clean: files.is_empty(),
|
|
files,
|
|
rebase_in_progress: rebase_in_progress(repo),
|
|
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
|
merge_in_progress: merge_in_progress(repo),
|
|
})
|
|
}
|
|
|
|
fn rebase_in_progress(repo: &Path) -> bool {
|
|
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
|
|
}
|
|
|
|
fn cherry_pick_in_progress(repo: &Path) -> bool {
|
|
git_path_exists(repo, "CHERRY_PICK_HEAD")
|
|
}
|
|
|
|
fn merge_in_progress(repo: &Path) -> bool {
|
|
git_path_exists(repo, "MERGE_HEAD")
|
|
}
|
|
|
|
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
|
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
|
return false;
|
|
};
|
|
let value = String::from_utf8_lossy(&output).trim().to_string();
|
|
if value.is_empty() {
|
|
return false;
|
|
}
|
|
let path = PathBuf::from(value);
|
|
if path.is_absolute() {
|
|
path.exists()
|
|
} else {
|
|
repo.join(path).exists()
|
|
}
|
|
}
|
|
|
|
// `git status` only auto-detects renames between HEAD and the index (staged changes).
|
|
// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked
|
|
// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
|
|
// deletion and an untracked file share the same blob hash (and the match is unambiguous),
|
|
// we merge them into a single unstaged "renamed" entry, mirroring how git reports staged
|
|
// renames. This is intentionally content-hash based (not similarity-based) so it never
|
|
// mutates the repository's real index.
|
|
const WORKTREE_RENAME_DETECTION_LIMIT: usize = 300;
|
|
|
|
fn detect_worktree_renames(repo: &Path, files: &mut Vec<GitFileStatus>) {
|
|
let deleted_paths: Vec<String> = files
|
|
.iter()
|
|
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
|
|
.map(|f| f.path.clone())
|
|
.collect();
|
|
let untracked_paths: Vec<String> = files
|
|
.iter()
|
|
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Untracked))
|
|
.map(|f| f.path.clone())
|
|
.collect();
|
|
|
|
if deleted_paths.is_empty()
|
|
|| untracked_paths.is_empty()
|
|
|| deleted_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
|
|| untracked_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
|
{
|
|
return;
|
|
}
|
|
|
|
let Ok(deleted_hashes) = index_blob_hashes(repo, &deleted_paths) else {
|
|
return;
|
|
};
|
|
let Ok(untracked_hashes) = worktree_blob_hashes(repo, &untracked_paths) else {
|
|
return;
|
|
};
|
|
|
|
let mut hash_to_deleted: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
|
for (path, hash) in &deleted_hashes {
|
|
hash_to_deleted.entry(hash).or_default().push(path);
|
|
}
|
|
let mut hash_to_untracked: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
|
for (path, hash) in &untracked_hashes {
|
|
hash_to_untracked.entry(hash).or_default().push(path);
|
|
}
|
|
|
|
let mut renames: Vec<(String, String)> = Vec::new();
|
|
for (hash, olds) in &hash_to_deleted {
|
|
if olds.len() != 1 {
|
|
continue;
|
|
}
|
|
if let Some(news) = hash_to_untracked.get(hash) {
|
|
if news.len() == 1 {
|
|
renames.push((olds[0].to_string(), news[0].to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
for (old_path, new_path) in renames {
|
|
files.retain(|f| {
|
|
!(f.path == old_path
|
|
&& f.staged.is_none()
|
|
&& f.unstaged == Some(FileStatusKind::Deleted))
|
|
&& !(f.path == new_path
|
|
&& f.staged.is_none()
|
|
&& f.unstaged == Some(FileStatusKind::Untracked))
|
|
});
|
|
files.push(GitFileStatus {
|
|
path: new_path,
|
|
old_path: Some(old_path),
|
|
staged: None,
|
|
unstaged: Some(FileStatusKind::Renamed),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Batched via `git ls-files -s -z` (one process for every deleted path) rather than one
|
|
// `git rev-parse` call per file, since this runs on every status refresh.
|
|
fn index_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
|
let mut args: Vec<OsString> = vec![
|
|
OsString::from("ls-files"),
|
|
OsString::from("-s"),
|
|
OsString::from("-z"),
|
|
OsString::from("--"),
|
|
];
|
|
args.extend(paths.iter().map(OsString::from));
|
|
let output = run_git(repo, args)?;
|
|
|
|
let mut result = Vec::new();
|
|
for entry in output.split(|byte| *byte == 0).filter(|e| !e.is_empty()) {
|
|
let text = String::from_utf8_lossy(entry);
|
|
let Some((meta, path)) = text.split_once('\t') else {
|
|
continue;
|
|
};
|
|
let Some(hash) = meta.split_whitespace().nth(1) else {
|
|
continue;
|
|
};
|
|
result.push((path.to_string(), hash.to_string()));
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
// Batched via `git hash-object --stdin-paths` (one process for every untracked path).
|
|
fn worktree_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
|
let stdin_data = paths.join("\n");
|
|
let output = run_git_with_stdin(
|
|
repo,
|
|
["hash-object", "--stdin-paths"],
|
|
stdin_data.as_bytes(),
|
|
)?;
|
|
|
|
let hashes: Vec<String> = String::from_utf8_lossy(&output)
|
|
.lines()
|
|
.map(|line| line.trim().to_string())
|
|
.filter(|line| !line.is_empty())
|
|
.collect();
|
|
|
|
Ok(paths.iter().cloned().zip(hashes).collect())
|
|
}
|
|
|
|
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
|
let status = status_for_repo(repo)?;
|
|
repository_files_with_status(repo, &status)
|
|
}
|
|
|
|
fn repository_files_with_status(
|
|
repo: &Path,
|
|
status: &GitStatus,
|
|
) -> Result<Vec<GitRepositoryFile>, String> {
|
|
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
|
|
|
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
|
for path in parse_nul_paths(&tracked_output) {
|
|
let status = status_for_file(&status.files, &path);
|
|
files.insert(
|
|
path.clone(),
|
|
GitRepositoryFile {
|
|
path,
|
|
tracked: true,
|
|
status,
|
|
},
|
|
);
|
|
}
|
|
|
|
let untracked_output = run_git(repo, ["ls-files", "-z", "--others", "--exclude-standard"])?;
|
|
for path in parse_nul_paths(&untracked_output) {
|
|
let status = status_for_file(&status.files, &path).or(Some(FileStatusKind::Untracked));
|
|
files.insert(
|
|
path.clone(),
|
|
GitRepositoryFile {
|
|
path,
|
|
tracked: false,
|
|
status,
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(files.into_values().collect())
|
|
}
|
|
|
|
fn clone_repository_core(
|
|
remote_url: &str,
|
|
parent_path: &str,
|
|
directory_name: Option<&str>,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
commit_limit: Option<u32>,
|
|
) -> Result<RepositoryBundle, String> {
|
|
let target = clone_target_path(remote_url, parent_path, directory_name)?;
|
|
run_git_clone(remote_url.trim(), &target, username, password)?;
|
|
|
|
let repo = resolve_repo(&target.to_string_lossy())?;
|
|
repository_bundle_for_repo(&repo, commit_limit)
|
|
}
|
|
|
|
fn clone_target_path(
|
|
remote_url: &str,
|
|
parent_path: &str,
|
|
directory_name: Option<&str>,
|
|
) -> Result<PathBuf, String> {
|
|
let remote = remote_url.trim();
|
|
if remote.is_empty() {
|
|
return Err("Remote URL must not be empty.".to_string());
|
|
}
|
|
if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) {
|
|
return Err("Remote URL contains invalid characters.".to_string());
|
|
}
|
|
|
|
let parent = PathBuf::from(parent_path.trim());
|
|
if parent_path.trim().is_empty() {
|
|
return Err("Destination folder must not be empty.".to_string());
|
|
}
|
|
if !parent.exists() {
|
|
return Err("Destination folder does not exist.".to_string());
|
|
}
|
|
if !parent.is_dir() {
|
|
return Err("Destination path must be a folder.".to_string());
|
|
}
|
|
|
|
let raw_name = directory_name
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| infer_clone_directory_name(remote));
|
|
let name = validate_clone_directory_name(&raw_name)?;
|
|
let target = parent.join(name);
|
|
|
|
if target.exists() {
|
|
if !target.is_dir() {
|
|
return Err("Clone destination already exists and is not a folder.".to_string());
|
|
}
|
|
let mut entries = target
|
|
.read_dir()
|
|
.map_err(|err| format!("Could not inspect clone destination: {err}"))?;
|
|
if entries.next().is_some() {
|
|
return Err("Clone destination already exists and is not empty.".to_string());
|
|
}
|
|
}
|
|
|
|
Ok(target)
|
|
}
|
|
|
|
fn infer_clone_directory_name(remote_url: &str) -> String {
|
|
let trimmed = remote_url
|
|
.trim()
|
|
.split(['?', '#'])
|
|
.next()
|
|
.unwrap_or(remote_url)
|
|
.trim_end_matches(['/', '\\']);
|
|
let last_segment = trimmed
|
|
.rsplit(['/', '\\', ':'])
|
|
.find(|part| !part.trim().is_empty())
|
|
.unwrap_or("")
|
|
.trim();
|
|
|
|
last_segment
|
|
.strip_suffix(".git")
|
|
.unwrap_or(last_segment)
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
fn validate_clone_directory_name(name: &str) -> Result<String, String> {
|
|
let trimmed = name.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("Folder name could not be inferred. Enter a folder name.".to_string());
|
|
}
|
|
if trimmed == "." || trimmed == ".." {
|
|
return Err("Folder name is not valid.".to_string());
|
|
}
|
|
if trimmed.chars().any(|c| {
|
|
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
|
|
}) {
|
|
return Err("Folder name contains invalid characters.".to_string());
|
|
}
|
|
if Path::new(trimmed).is_absolute() {
|
|
return Err("Folder name must be relative.".to_string());
|
|
}
|
|
|
|
Ok(trimmed.to_string())
|
|
}
|
|
|
|
fn run_git_clone(
|
|
remote_url: &str,
|
|
target: &Path,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
) -> Result<(), String> {
|
|
let mut command = git_command();
|
|
command
|
|
.arg("clone")
|
|
.arg("--")
|
|
.arg(remote_url)
|
|
.arg(target)
|
|
.env("GIT_TERMINAL_PROMPT", "0");
|
|
|
|
let askpass = match (username, password) {
|
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
|
let askpass = write_askpass_script()?;
|
|
command
|
|
.env("GIT_ASKPASS", &askpass)
|
|
.env("GIT_CRED_USER", u)
|
|
.env("GIT_CRED_PASS", p);
|
|
Some(askpass)
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
let output = command
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
|
|
if let Some(path) = askpass {
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
let output = output?;
|
|
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
|
|
let details = command_output_details(&output);
|
|
if is_auth_error(&details) {
|
|
return Err(format!("AUTH_FAILED:{details}"));
|
|
}
|
|
|
|
Err(format!("Git clone failed: {}", details))
|
|
}
|
|
|
|
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
|
|
let normalized = normalize_git_path(path);
|
|
if repo.join(path).is_dir() {
|
|
return Ok(true);
|
|
}
|
|
|
|
let prefix = format!("{normalized}/");
|
|
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
|
Ok(parse_nul_paths(&tracked_output)
|
|
.iter()
|
|
.any(|tracked_path| normalize_git_path(tracked_path).starts_with(&prefix)))
|
|
}
|
|
|
|
fn normalize_git_path(path: &str) -> String {
|
|
path.replace('\\', "/").trim_matches('/').to_string()
|
|
}
|
|
|
|
fn parse_nul_paths(output: &[u8]) -> Vec<String> {
|
|
output
|
|
.split(|byte| *byte == 0)
|
|
.filter(|entry| !entry.is_empty())
|
|
.map(|entry| String::from_utf8_lossy(entry).to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn status_for_file(statuses: &[GitFileStatus], path: &str) -> Option<FileStatusKind> {
|
|
let status = find_status(statuses, path)?;
|
|
|
|
if matches!(status.staged, Some(FileStatusKind::Conflicted))
|
|
|| matches!(status.unstaged, Some(FileStatusKind::Conflicted))
|
|
{
|
|
return Some(FileStatusKind::Conflicted);
|
|
}
|
|
|
|
status.unstaged.or(status.staged)
|
|
}
|
|
|
|
fn has_unresolved_conflicts(status: &GitStatus) -> bool {
|
|
status.files.iter().any(|file| {
|
|
matches!(file.staged, Some(FileStatusKind::Conflicted))
|
|
|| matches!(file.unstaged, Some(FileStatusKind::Conflicted))
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct GitSearchCommitMetadata {
|
|
commit_hash: String,
|
|
short_hash: String,
|
|
author_name: String,
|
|
author_email: String,
|
|
date: String,
|
|
summary: String,
|
|
}
|
|
|
|
fn search_candidate_commits(
|
|
repo: &Path,
|
|
query: &str,
|
|
case_sensitive: bool,
|
|
cancellation: Option<&SearchCancellation>,
|
|
) -> Result<Vec<String>, String> {
|
|
let output = if query.contains('\n') {
|
|
run_git_cancellable(
|
|
repo,
|
|
["rev-list", "--all", "--reverse"],
|
|
cancellation,
|
|
"Git search failed",
|
|
)?
|
|
} else {
|
|
let mut args = vec![
|
|
OsString::from("log"),
|
|
OsString::from("--all"),
|
|
OsString::from("--reverse"),
|
|
OsString::from("--format=%H"),
|
|
// Skip textconv diff drivers so git does not extract binary files
|
|
// (e.g. .docx / Office temp "~$" lock files) to temp files, which can
|
|
// fail with "unsupported filetype" and abort the whole search.
|
|
OsString::from("--no-textconv"),
|
|
];
|
|
if !case_sensitive {
|
|
args.push(OsString::from("-i"));
|
|
}
|
|
args.push(OsString::from(format!("-S{query}")));
|
|
run_git_cancellable(repo, args, cancellation, "Git search failed")?
|
|
};
|
|
|
|
Ok(String::from_utf8_lossy(&output)
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.map(ToString::to_string)
|
|
.collect())
|
|
}
|
|
|
|
fn commit_parents(repo: &Path, commit: &str) -> Result<Vec<String>, String> {
|
|
let output = run_git(repo, ["rev-list", "--parents", "-n", "1", commit])?;
|
|
let text = String::from_utf8_lossy(&output);
|
|
Ok(text
|
|
.split_whitespace()
|
|
.skip(1)
|
|
.map(ToString::to_string)
|
|
.collect())
|
|
}
|
|
|
|
fn max_parent_match_count(
|
|
repo: &Path,
|
|
parents: &[String],
|
|
file: &str,
|
|
query: &str,
|
|
case_sensitive: bool,
|
|
) -> Result<usize, String> {
|
|
let mut max_count = 0;
|
|
for parent in parents {
|
|
if let Some(content) = read_text_blob(repo, parent, file)? {
|
|
max_count = max_count.max(count_matches(&content, query, case_sensitive));
|
|
}
|
|
}
|
|
Ok(max_count)
|
|
}
|
|
|
|
fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result<Option<String>, String> {
|
|
let spec = format!("{commit}:{file}");
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["show", spec.as_str()])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
|
|
if is_binary_bytes(&output.stdout) {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(normalize_newlines(&String::from_utf8_lossy(
|
|
&output.stdout,
|
|
))))
|
|
}
|
|
|
|
fn normalize_newlines(value: &str) -> String {
|
|
value.replace("\r\n", "\n").replace('\r', "\n")
|
|
}
|
|
|
|
fn count_matches(content: &str, query: &str, case_sensitive: bool) -> usize {
|
|
let content = if case_sensitive {
|
|
content.to_string()
|
|
} else {
|
|
content.to_ascii_lowercase()
|
|
};
|
|
let query = if case_sensitive {
|
|
query.to_string()
|
|
} else {
|
|
query.to_ascii_lowercase()
|
|
};
|
|
|
|
if query.is_empty() {
|
|
return 0;
|
|
}
|
|
|
|
let mut count = 0;
|
|
let mut start = 0;
|
|
while let Some(index) = content[start..].find(&query) {
|
|
count += 1;
|
|
start += index + query.len();
|
|
}
|
|
count
|
|
}
|
|
|
|
fn first_added_match_line(
|
|
repo: &Path,
|
|
parent: Option<&str>,
|
|
commit: &str,
|
|
file: &str,
|
|
query: &str,
|
|
case_sensitive: bool,
|
|
cancellation: Option<&SearchCancellation>,
|
|
) -> Result<Option<(u32, String)>, String> {
|
|
let Some(parent) = parent else {
|
|
return Ok(None);
|
|
};
|
|
|
|
check_search_cancelled(cancellation)?;
|
|
let output = run_git_with_paths_cancellable(
|
|
repo,
|
|
&["diff", "--no-textconv", "--unified=0", parent, commit],
|
|
&[file.to_string()],
|
|
cancellation,
|
|
"Git diff for search result failed",
|
|
)?;
|
|
let patch = String::from_utf8_lossy(&output);
|
|
let line_query = first_query_line(query);
|
|
let mut new_line = 0u32;
|
|
|
|
for line in patch.lines() {
|
|
check_search_cancelled(cancellation)?;
|
|
if line.starts_with("@@") {
|
|
if let Some(start) = parse_new_hunk_start(line) {
|
|
new_line = start;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") {
|
|
continue;
|
|
}
|
|
|
|
if let Some(added) = line.strip_prefix('+') {
|
|
if count_matches(added, line_query, case_sensitive) > 0 {
|
|
return Ok(Some((new_line.max(1), compact_search_line(added))));
|
|
}
|
|
new_line = new_line.saturating_add(1);
|
|
} else if line.starts_with('-') {
|
|
continue;
|
|
} else if line.starts_with(' ') {
|
|
new_line = new_line.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn first_query_line(query: &str) -> &str {
|
|
query
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|line| !line.is_empty())
|
|
.unwrap_or(query)
|
|
}
|
|
|
|
fn parse_new_hunk_start(line: &str) -> Option<u32> {
|
|
let plus = line.split_whitespace().find(|part| part.starts_with('+'))?;
|
|
let number = plus
|
|
.trim_start_matches('+')
|
|
.split_once(',')
|
|
.map(|(start, _)| start)
|
|
.unwrap_or_else(|| plus.trim_start_matches('+'));
|
|
number.parse().ok()
|
|
}
|
|
|
|
fn first_match_line(content: &str, query: &str, case_sensitive: bool) -> Option<(u32, String)> {
|
|
let haystack = if case_sensitive {
|
|
content.to_string()
|
|
} else {
|
|
content.to_ascii_lowercase()
|
|
};
|
|
let needle = if case_sensitive {
|
|
query.to_string()
|
|
} else {
|
|
query.to_ascii_lowercase()
|
|
};
|
|
let index = haystack.find(&needle)?;
|
|
let line_number = content[..index]
|
|
.bytes()
|
|
.filter(|byte| *byte == b'\n')
|
|
.count() as u32
|
|
+ 1;
|
|
let line_start = content[..index].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
|
|
let line_end = content[index..]
|
|
.find('\n')
|
|
.map(|pos| index + pos)
|
|
.unwrap_or(content.len());
|
|
Some((
|
|
line_number,
|
|
compact_search_line(&content[line_start..line_end]),
|
|
))
|
|
}
|
|
|
|
fn compact_search_line(line: &str) -> String {
|
|
const MAX_LEN: usize = 240;
|
|
let compact = line.trim().replace('\t', " ");
|
|
if compact.chars().count() <= MAX_LEN {
|
|
return compact;
|
|
}
|
|
|
|
let mut shortened: String = compact.chars().take(MAX_LEN).collect();
|
|
shortened.push_str("...");
|
|
shortened
|
|
}
|
|
|
|
fn commit_search_metadata(repo: &Path, commit: &str) -> Result<GitSearchCommitMetadata, String> {
|
|
let output = run_git(
|
|
repo,
|
|
[
|
|
"show",
|
|
"-s",
|
|
"--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s",
|
|
commit,
|
|
],
|
|
)?;
|
|
let text = String::from_utf8_lossy(&output);
|
|
let fields: Vec<&str> = text.trim_end().splitn(6, '\x1f').collect();
|
|
if fields.len() != 6 {
|
|
return Err(format!("Unexpected Git commit entry: {text}"));
|
|
}
|
|
|
|
Ok(GitSearchCommitMetadata {
|
|
commit_hash: fields[0].to_string(),
|
|
short_hash: fields[1].to_string(),
|
|
author_name: fields[2].to_string(),
|
|
author_email: fields[3].to_string(),
|
|
date: fields[4].to_string(),
|
|
summary: fields[5].to_string(),
|
|
})
|
|
}
|
|
|
|
/// Parses `git log --name-status -z` output where each commit's changed files
|
|
/// are embedded inline (see `commits_for_repo`), so no per-commit git process is
|
|
/// needed. Record layout: `\x1e` then eight `\x1f`-separated header fields, then
|
|
/// git's newline, then the NUL-separated name-status entries.
|
|
fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
|
const FIELD_SEPARATOR: u8 = 0x1f;
|
|
const RECORD_SEPARATOR: u8 = 0x1e;
|
|
|
|
let mut commits = Vec::new();
|
|
|
|
for record in output.split(|byte| *byte == RECORD_SEPARATOR) {
|
|
// Skip the empty leading chunk and any stray separators left by `-z`.
|
|
if record
|
|
.iter()
|
|
.all(|&byte| matches!(byte, 0 | b'\n' | b'\r' | b' ' | b'\t'))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect();
|
|
if parts.len() < 8 {
|
|
return Err(format!(
|
|
"Unexpected Git log entry: {}",
|
|
String::from_utf8_lossy(record)
|
|
));
|
|
}
|
|
|
|
// Field 8 (if present) holds the name-status list, preceded by the newline
|
|
// git inserts between the pretty-format output and the diff.
|
|
let mut files_bytes: &[u8] = parts.get(8).copied().unwrap_or(&[]);
|
|
while let Some((&first, rest)) = files_bytes.split_first() {
|
|
if matches!(first, b'\n' | b'\r') {
|
|
files_bytes = rest;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let refs = String::from_utf8_lossy(parts[5])
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
let parents = String::from_utf8_lossy(parts[6])
|
|
.split_whitespace()
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
|
|
commits.push(GitCommit {
|
|
hash: String::from_utf8_lossy(parts[0]).trim().to_string(),
|
|
short_hash: String::from_utf8_lossy(parts[1]).trim().to_string(),
|
|
author_name: String::from_utf8_lossy(parts[2]).to_string(),
|
|
author_email: String::from_utf8_lossy(parts[3]).to_string(),
|
|
date: String::from_utf8_lossy(parts[4]).trim().to_string(),
|
|
refs,
|
|
parents,
|
|
summary: String::from_utf8_lossy(parts[7]).to_string(),
|
|
files: parse_commit_files(files_bytes)?,
|
|
});
|
|
}
|
|
|
|
Ok(commits)
|
|
}
|
|
|
|
fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
|
const FIELD_SEPARATOR: char = '\x1f';
|
|
const RECORD_SEPARATOR: char = '\x1e';
|
|
|
|
let text = String::from_utf8_lossy(output);
|
|
let mut commits = Vec::new();
|
|
|
|
for record in text.split(RECORD_SEPARATOR).map(str::trim) {
|
|
if record.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
|
|
if fields.len() != 8 {
|
|
return Err(format!("Unexpected Git log entry: {record}"));
|
|
}
|
|
|
|
let refs = fields[5]
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
|
|
let parents = fields[6]
|
|
.split_whitespace()
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
|
|
let hash = fields[0].to_string();
|
|
let files = commit_files(repo, &hash)?;
|
|
|
|
commits.push(GitCommit {
|
|
hash,
|
|
short_hash: fields[1].to_string(),
|
|
author_name: fields[2].to_string(),
|
|
author_email: fields[3].to_string(),
|
|
date: fields[4].to_string(),
|
|
refs,
|
|
parents,
|
|
summary: fields[7].to_string(),
|
|
files,
|
|
});
|
|
}
|
|
|
|
Ok(commits)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
|
const FIELD_SEPARATOR: char = '\x1f';
|
|
const RECORD_SEPARATOR: char = '\x1e';
|
|
|
|
let text = String::from_utf8_lossy(output);
|
|
let mut commits = Vec::new();
|
|
|
|
for record in text.split(RECORD_SEPARATOR).map(str::trim) {
|
|
if record.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
|
|
if fields.len() != 8 {
|
|
return Err(format!("Unexpected Git log entry: {record}"));
|
|
}
|
|
|
|
let refs = fields[5]
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
|
|
let parents = fields[6]
|
|
.split_whitespace()
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
|
|
commits.push(GitCommit {
|
|
hash: fields[0].to_string(),
|
|
short_hash: fields[1].to_string(),
|
|
author_name: fields[2].to_string(),
|
|
author_email: fields[3].to_string(),
|
|
date: fields[4].to_string(),
|
|
refs,
|
|
parents,
|
|
summary: fields[7].to_string(),
|
|
files: Vec::new(),
|
|
});
|
|
}
|
|
|
|
Ok(commits)
|
|
}
|
|
|
|
fn commit_files(repo: &Path, commit: &str) -> Result<Vec<GitCommitFile>, String> {
|
|
let output = run_git(
|
|
repo,
|
|
[
|
|
"diff-tree",
|
|
"--root",
|
|
"--no-commit-id",
|
|
"--name-status",
|
|
"-r",
|
|
"-M",
|
|
"-z",
|
|
commit,
|
|
],
|
|
)?;
|
|
|
|
parse_commit_files(&output)
|
|
}
|
|
|
|
fn parse_commit_files(output: &[u8]) -> Result<Vec<GitCommitFile>, String> {
|
|
let entries: Vec<&[u8]> = output
|
|
.split(|byte| *byte == 0)
|
|
.filter(|entry| !entry.is_empty())
|
|
.collect();
|
|
|
|
let mut files = Vec::new();
|
|
let mut index = 0;
|
|
|
|
while index < entries.len() {
|
|
let status_text = String::from_utf8_lossy(entries[index]).to_string();
|
|
index += 1;
|
|
let status = map_name_status(&status_text);
|
|
|
|
if index >= entries.len() {
|
|
return Err(format!("Git diff entry without path: {status_text}"));
|
|
}
|
|
|
|
if matches!(status, FileStatusKind::Renamed) {
|
|
let old_path = String::from_utf8_lossy(entries[index]).to_string();
|
|
index += 1;
|
|
|
|
if index >= entries.len() {
|
|
return Err(format!("Git-Rename ohne Zielpfad: {old_path}"));
|
|
}
|
|
|
|
let path = String::from_utf8_lossy(entries[index]).to_string();
|
|
index += 1;
|
|
files.push(GitCommitFile {
|
|
path,
|
|
old_path: Some(old_path),
|
|
status,
|
|
});
|
|
} else {
|
|
let path = String::from_utf8_lossy(entries[index]).to_string();
|
|
index += 1;
|
|
files.push(GitCommitFile {
|
|
path,
|
|
old_path: None,
|
|
status,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(files)
|
|
}
|
|
|
|
fn parse_diff_files(name_status: &[u8], numstat: &[u8]) -> Result<Vec<GitDiffFile>, String> {
|
|
let status_files = parse_commit_files(name_status)?;
|
|
let counts = parse_numstat_z(numstat);
|
|
|
|
let files = status_files
|
|
.into_iter()
|
|
.map(|file| {
|
|
let (additions, deletions) = counts
|
|
.iter()
|
|
.find(|(path, _, _)| *path == file.path)
|
|
.map(|(_, additions, deletions)| (*additions, *deletions))
|
|
.unwrap_or((0, 0));
|
|
|
|
GitDiffFile {
|
|
path: file.path,
|
|
old_path: file.old_path,
|
|
status: file.status,
|
|
additions,
|
|
deletions,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(files)
|
|
}
|
|
|
|
fn parse_numstat_z(output: &[u8]) -> Vec<(String, u32, u32)> {
|
|
let tokens: Vec<String> = output
|
|
.split(|byte| *byte == 0)
|
|
.filter(|entry| !entry.is_empty())
|
|
.map(|entry| String::from_utf8_lossy(entry).to_string())
|
|
.collect();
|
|
|
|
let mut result = Vec::new();
|
|
let mut index = 0;
|
|
|
|
while index < tokens.len() {
|
|
let mut parts = tokens[index].splitn(3, '\t');
|
|
let additions = parse_numstat_count(parts.next().unwrap_or(""));
|
|
let deletions = parse_numstat_count(parts.next().unwrap_or(""));
|
|
let rest = parts.next().unwrap_or("").to_string();
|
|
index += 1;
|
|
|
|
// For renames/copies, `--numstat -z` leaves the path empty and emits the
|
|
// old and new paths as two separate NUL-terminated tokens.
|
|
let path = if rest.is_empty() {
|
|
if index + 1 >= tokens.len() {
|
|
break;
|
|
}
|
|
let new_path = tokens[index + 1].clone();
|
|
index += 2;
|
|
new_path
|
|
} else {
|
|
rest
|
|
};
|
|
|
|
result.push((path, additions, deletions));
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn parse_numstat_count(value: &str) -> u32 {
|
|
value.trim().parse().unwrap_or(0)
|
|
}
|
|
|
|
fn map_name_status(status: &str) -> FileStatusKind {
|
|
match status.chars().next() {
|
|
Some('M') => FileStatusKind::Modified,
|
|
Some('A') => FileStatusKind::Added,
|
|
Some('D') => FileStatusKind::Deleted,
|
|
Some('R') => FileStatusKind::Renamed,
|
|
Some('C') => FileStatusKind::Added,
|
|
Some('U') => FileStatusKind::Conflicted,
|
|
_ => FileStatusKind::Unknown,
|
|
}
|
|
}
|
|
|
|
fn checkout_plan(repo: &Path, branch: &str) -> Result<CheckoutPlan, String> {
|
|
if ref_exists(repo, &format!("refs/heads/{branch}"))? {
|
|
return Ok(CheckoutPlan::Local(branch.to_string()));
|
|
}
|
|
|
|
if ref_exists(repo, &format!("refs/remotes/{branch}"))? {
|
|
let Some(local) = local_branch_name_for_remote(branch) else {
|
|
return Ok(CheckoutPlan::Raw(branch.to_string()));
|
|
};
|
|
|
|
if ref_exists(repo, &format!("refs/heads/{local}"))? {
|
|
return Ok(CheckoutPlan::Local(local.to_string()));
|
|
}
|
|
|
|
return Ok(CheckoutPlan::TrackRemote {
|
|
local: local.to_string(),
|
|
remote: branch.to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(CheckoutPlan::Raw(branch.to_string()))
|
|
}
|
|
|
|
fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> {
|
|
remote_branch
|
|
.split_once('/')
|
|
.map(|(_, local)| local)
|
|
.filter(|local| !local.is_empty())
|
|
}
|
|
|
|
fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
|
|
let branch = branch.trim();
|
|
if branch.is_empty() {
|
|
return Err("Branch name must not be empty.".to_string());
|
|
}
|
|
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["check-ref-format", "--branch", branch])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if !output.status.success() {
|
|
let details = command_output_details(&output);
|
|
return Err(format!("Invalid branch name: {details}"));
|
|
}
|
|
|
|
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let normalized = if normalized.is_empty() {
|
|
branch.to_string()
|
|
} else {
|
|
normalized
|
|
};
|
|
|
|
if ref_exists(repo, &format!("refs/heads/{normalized}"))? {
|
|
return Err(format!("Branch '{normalized}' already exists."));
|
|
}
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
|
|
let branch = branch.trim();
|
|
if branch.is_empty() {
|
|
return Err("Branch name must not be empty.".to_string());
|
|
}
|
|
|
|
let normalized = validate_branch_ref_name(branch)?;
|
|
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
|
|
return Err(format!("Local branch '{normalized}' was not found."));
|
|
}
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn validate_branch_ref_name(branch: &str) -> Result<String, String> {
|
|
let output = git_command()
|
|
.args(["check-ref-format", "--branch", branch])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if !output.status.success() {
|
|
let details = command_output_details(&output);
|
|
return Err(format!("Invalid branch name: {details}"));
|
|
}
|
|
|
|
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
Ok(if normalized.is_empty() {
|
|
branch.to_string()
|
|
} else {
|
|
normalized
|
|
})
|
|
}
|
|
|
|
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["show-ref", "--verify", "--quiet", ref_name])
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
match output.status.code() {
|
|
Some(0) => Ok(true),
|
|
Some(1) => Ok(false),
|
|
_ => {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let details = if stderr.trim().is_empty() {
|
|
"unknown error".to_string()
|
|
} else {
|
|
stderr.trim().to_string()
|
|
};
|
|
Err(format!("Could not verify Git ref: {details}"))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn restore_worktree_files(
|
|
repo: &Path,
|
|
statuses: &[GitFileStatus],
|
|
files: &[String],
|
|
) -> Result<(), String> {
|
|
let mut restore_paths = Vec::new();
|
|
let mut clean_paths = Vec::new();
|
|
|
|
for file in files {
|
|
let status = find_status(statuses, file);
|
|
match status.and_then(|entry| entry.unstaged) {
|
|
Some(FileStatusKind::Untracked) => clean_paths.push(file.clone()),
|
|
// An unstaged rename (see `detect_worktree_renames`) has no index entry for the
|
|
// new path, so `git restore` can't act on it directly: restore the original
|
|
// content at the old path and drop the untracked new file instead.
|
|
Some(FileStatusKind::Renamed) => {
|
|
if let Some(entry) = status {
|
|
if let Some(old_path) = entry.old_path.clone() {
|
|
restore_paths.push(old_path);
|
|
}
|
|
clean_paths.push(entry.path.clone());
|
|
} else {
|
|
restore_paths.push(file.clone());
|
|
}
|
|
}
|
|
_ => restore_paths.push(file.clone()),
|
|
}
|
|
}
|
|
|
|
if !restore_paths.is_empty() {
|
|
run_git_with_paths(repo, &["restore", "--worktree"], &restore_paths)?;
|
|
}
|
|
|
|
if !clean_paths.is_empty() {
|
|
run_git_with_paths(repo, &["clean", "-fd"], &clean_paths)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn unstage_selected_files(
|
|
repo: &Path,
|
|
statuses: &[GitFileStatus],
|
|
files: &[String],
|
|
) -> Result<(), String> {
|
|
let mut restore_paths = Vec::new();
|
|
let mut remove_from_index = Vec::new();
|
|
|
|
for file in files {
|
|
let status = find_status(statuses, file);
|
|
if matches!(
|
|
status.and_then(|entry| entry.staged),
|
|
Some(FileStatusKind::Added)
|
|
) && status.and_then(|entry| entry.old_path.as_ref()).is_none()
|
|
{
|
|
remove_from_index.push(file.clone());
|
|
} else {
|
|
restore_paths.push(file.clone());
|
|
}
|
|
}
|
|
|
|
if !restore_paths.is_empty() {
|
|
run_git_with_paths(repo, &["restore", "--staged"], &restore_paths)?;
|
|
}
|
|
|
|
if !remove_from_index.is_empty() {
|
|
run_git_with_paths(repo, &["rm", "--cached", "-f"], &remove_from_index)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn restore_staged_files(
|
|
repo: &Path,
|
|
statuses: &[GitFileStatus],
|
|
files: &[String],
|
|
) -> Result<(), String> {
|
|
let mut restore_paths = Vec::new();
|
|
let mut remove_from_index = Vec::new();
|
|
let mut clean_paths = Vec::new();
|
|
|
|
for file in files {
|
|
let status = find_status(statuses, file);
|
|
match status.and_then(|entry| entry.staged) {
|
|
Some(FileStatusKind::Added) => {
|
|
let target = status
|
|
.map(|entry| entry.path.clone())
|
|
.unwrap_or_else(|| file.clone());
|
|
remove_from_index.push(target.clone());
|
|
clean_paths.push(target);
|
|
}
|
|
Some(FileStatusKind::Renamed) => {
|
|
if let Some(entry) = status {
|
|
if let Some(old_path) = entry.old_path.clone() {
|
|
restore_paths.push(old_path);
|
|
remove_from_index.push(entry.path.clone());
|
|
clean_paths.push(entry.path.clone());
|
|
} else {
|
|
restore_paths.push(entry.path.clone());
|
|
}
|
|
} else {
|
|
restore_paths.push(file.clone());
|
|
}
|
|
}
|
|
Some(_) => {
|
|
if let Some(entry) = status {
|
|
restore_paths.push(entry.path.clone());
|
|
} else {
|
|
restore_paths.push(file.clone());
|
|
}
|
|
}
|
|
None => {
|
|
if matches!(
|
|
status.and_then(|entry| entry.unstaged),
|
|
Some(FileStatusKind::Untracked)
|
|
) {
|
|
clean_paths.push(file.clone());
|
|
} else {
|
|
restore_paths.push(file.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !restore_paths.is_empty() {
|
|
run_git_with_paths(
|
|
repo,
|
|
&["restore", "--source=HEAD", "--staged", "--worktree"],
|
|
&restore_paths,
|
|
)?;
|
|
}
|
|
|
|
if !remove_from_index.is_empty() {
|
|
run_git_with_paths(repo, &["rm", "--cached", "-f"], &remove_from_index)?;
|
|
}
|
|
|
|
if !clean_paths.is_empty() {
|
|
run_git_with_paths(repo, &["clean", "-fd"], &clean_paths)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn find_status<'a>(statuses: &'a [GitFileStatus], file: &str) -> Option<&'a GitFileStatus> {
|
|
statuses
|
|
.iter()
|
|
.find(|entry| entry.path == file || entry.old_path.as_deref() == Some(file))
|
|
}
|
|
|
|
fn validate_files(files: &[String]) -> Result<(), String> {
|
|
if files.iter().any(|file| file.is_empty()) {
|
|
return Err("File list contains an empty path.".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
|
|
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"gitlite_patch_{}_{}.patch",
|
|
std::process::id(),
|
|
counter
|
|
));
|
|
std::fs::write(&path, patch.as_bytes())
|
|
.map_err(|err| format!("Could not write patch file: {err}"))?;
|
|
Ok(path)
|
|
}
|
|
|
|
fn check_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
|
|
run_apply_patch_command(repo, patch_path, options, true)
|
|
}
|
|
|
|
fn run_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
|
|
run_apply_patch_command(repo, patch_path, options, false)
|
|
}
|
|
|
|
fn run_apply_patch_command(
|
|
repo: &Path,
|
|
patch_path: &Path,
|
|
options: &[&str],
|
|
check_only: bool,
|
|
) -> Result<(), String> {
|
|
let mut args = Vec::with_capacity(options.len() + 5);
|
|
args.push(OsString::from("apply"));
|
|
if check_only {
|
|
args.push(OsString::from("--check"));
|
|
}
|
|
args.extend(options.iter().map(OsString::from));
|
|
args.push(OsString::from("--recount"));
|
|
args.push(OsString::from("--whitespace=nowarn"));
|
|
args.push(patch_path.as_os_str().to_os_string());
|
|
|
|
run_git(repo, args).map(|_| ())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let path = std::env::temp_dir().join("gitlite_askpass.sh");
|
|
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
|
|
std::fs::write(&path, script)
|
|
.map_err(|e| format!("Could not write authentication script: {e}"))?;
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
|
|
.map_err(|e| format!("Could not set script permissions: {e}"))?;
|
|
Ok(path)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
|
let path = std::env::temp_dir().join("gitlite_askpass.bat");
|
|
let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n";
|
|
std::fs::write(&path, script)
|
|
.map_err(|e| format!("Could not write authentication script: {e}"))?;
|
|
Ok(path)
|
|
}
|
|
|
|
fn run_git_authenticated<I, S>(
|
|
repo: &Path,
|
|
args: I,
|
|
username: &str,
|
|
password: &str,
|
|
) -> Result<Vec<u8>, String>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let output = run_git_authenticated_output(repo, args, username, password)?;
|
|
|
|
if output.status.success() {
|
|
return Ok(output.stdout);
|
|
}
|
|
|
|
let details = command_output_details(&output);
|
|
if is_auth_error(&details) {
|
|
return Err(format!("AUTH_FAILED:{details}"));
|
|
}
|
|
Err(format!("Git command failed: {details}"))
|
|
}
|
|
|
|
fn run_git_authenticated_output<I, S>(
|
|
repo: &Path,
|
|
args: I,
|
|
username: &str,
|
|
password: &str,
|
|
) -> Result<Output, String>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let askpass = write_askpass_script()?;
|
|
|
|
let result = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.env("GIT_ASKPASS", &askpass)
|
|
.env("GIT_TERMINAL_PROMPT", "0")
|
|
.env("GIT_CRED_USER", username)
|
|
.env("GIT_CRED_PASS", password)
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git: {err}"));
|
|
|
|
let _ = std::fs::remove_file(&askpass);
|
|
result
|
|
}
|
|
|
|
fn command_output_details(output: &Output) -> String {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
if !stderr.trim().is_empty() {
|
|
stderr.trim().to_string()
|
|
} else if !stdout.trim().is_empty() {
|
|
stdout.trim().to_string()
|
|
} else {
|
|
"Unknown error".to_string()
|
|
}
|
|
}
|
|
|
|
/// Heuristic: did git fail because the credentials were rejected/expired,
|
|
/// as opposed to a network or merge error? Used by the frontend to drop the
|
|
/// stored credential and re-prompt the login.
|
|
fn is_auth_error(details: &str) -> bool {
|
|
let d = details.to_lowercase();
|
|
d.contains("authentication failed")
|
|
|| d.contains("could not read username")
|
|
|| d.contains("could not read password")
|
|
|| d.contains("invalid username or password")
|
|
|| d.contains("terminal prompts disabled")
|
|
|| d.contains("permission denied")
|
|
|| d.contains("access denied")
|
|
|| d.contains("403 forbidden")
|
|
|| d.contains(" 403")
|
|
|| d.contains(" 401")
|
|
|| d.contains("authorization failed")
|
|
|| d.contains("authentication required")
|
|
// Server-side message (e.g. Gitea/Forgejo: "remote: Failed to
|
|
// authenticate user"), independent of the local git locale.
|
|
|| d.contains("failed to authenticate")
|
|
}
|
|
|
|
// Windows' CreateProcess rejects command lines longer than ~32K chars with
|
|
// "os error 206" (filename or extension too long). Staging/restoring a large
|
|
// number of files can easily exceed that, so split the paths across multiple
|
|
// invocations and concatenate their output.
|
|
const MAX_PATH_ARGS_CHARS: usize = 8_000;
|
|
|
|
fn run_git_with_paths(
|
|
repo: &Path,
|
|
base_args: &[&str],
|
|
files: &[String],
|
|
) -> Result<Vec<u8>, String> {
|
|
if files.is_empty() {
|
|
let args: Vec<OsString> = base_args.iter().map(OsString::from).collect();
|
|
return run_git(repo, args);
|
|
}
|
|
|
|
let mut combined = Vec::new();
|
|
let mut start = 0;
|
|
while start < files.len() {
|
|
let mut end = start;
|
|
let mut chunk_chars = 0usize;
|
|
while end < files.len() {
|
|
let len = files[end].len() + 1;
|
|
if end > start && chunk_chars + len > MAX_PATH_ARGS_CHARS {
|
|
break;
|
|
}
|
|
chunk_chars += len;
|
|
end += 1;
|
|
}
|
|
let chunk = &files[start..end];
|
|
|
|
let mut args = Vec::with_capacity(base_args.len() + chunk.len() + 1);
|
|
args.extend(base_args.iter().map(OsString::from));
|
|
args.push(OsString::from("--"));
|
|
args.extend(chunk.iter().map(OsString::from));
|
|
combined.extend(run_git(repo, args)?);
|
|
|
|
start = end;
|
|
}
|
|
|
|
Ok(combined)
|
|
}
|
|
|
|
fn run_git_with_paths_cancellable(
|
|
repo: &Path,
|
|
base_args: &[&str],
|
|
files: &[String],
|
|
cancellation: Option<&SearchCancellation>,
|
|
context: &str,
|
|
) -> Result<Vec<u8>, String> {
|
|
let mut args = Vec::with_capacity(base_args.len() + files.len() + 1);
|
|
args.extend(base_args.iter().map(OsString::from));
|
|
args.push(OsString::from("--"));
|
|
args.extend(files.iter().map(OsString::from));
|
|
run_git_cancellable(repo, args, cancellation, context)
|
|
}
|
|
|
|
fn run_git<I, S>(repo: &Path, args: I) -> Result<Vec<u8>, String>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
run_git_at(repo, args, "Git command failed")
|
|
}
|
|
|
|
fn run_git_cancellable<I, S>(
|
|
repo: &Path,
|
|
args: I,
|
|
cancellation: Option<&SearchCancellation>,
|
|
context: &str,
|
|
) -> Result<Vec<u8>, String>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
check_search_cancelled(cancellation)?;
|
|
|
|
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let temp_dir = std::env::temp_dir();
|
|
let stdout_path = temp_dir.join(format!(
|
|
"gitlite_search_{}_{}.out",
|
|
std::process::id(),
|
|
counter
|
|
));
|
|
let stderr_path = temp_dir.join(format!(
|
|
"gitlite_search_{}_{}.err",
|
|
std::process::id(),
|
|
counter
|
|
));
|
|
let stdout_file = std::fs::File::create(&stdout_path)
|
|
.map_err(|err| format!("Could not create Git output file: {err}"))?;
|
|
let stderr_file = std::fs::File::create(&stderr_path)
|
|
.map_err(|err| format!("Could not create Git error file: {err}"))?;
|
|
|
|
let mut child = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.stdout(Stdio::from(stdout_file))
|
|
.stderr(Stdio::from(stderr_file))
|
|
.spawn()
|
|
.map_err(|err| {
|
|
let _ = std::fs::remove_file(&stdout_path);
|
|
let _ = std::fs::remove_file(&stderr_path);
|
|
format!("Could not start Git. Is Git installed? {err}")
|
|
})?;
|
|
|
|
let status = loop {
|
|
if let Err(err) = check_search_cancelled(cancellation) {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
let _ = std::fs::remove_file(&stdout_path);
|
|
let _ = std::fs::remove_file(&stderr_path);
|
|
return Err(err);
|
|
}
|
|
|
|
if let Some(status) = child
|
|
.try_wait()
|
|
.map_err(|err| format!("Could not check Git process: {err}"))?
|
|
{
|
|
break status;
|
|
}
|
|
|
|
thread::sleep(Duration::from_millis(60));
|
|
};
|
|
|
|
let stdout =
|
|
std::fs::read(&stdout_path).map_err(|err| format!("Could not read Git output: {err}"))?;
|
|
let stderr = std::fs::read(&stderr_path)
|
|
.map_err(|err| format!("Could not read Git error output: {err}"))?;
|
|
let _ = std::fs::remove_file(&stdout_path);
|
|
let _ = std::fs::remove_file(&stderr_path);
|
|
|
|
if status.success() {
|
|
return Ok(stdout);
|
|
}
|
|
|
|
let stderr_text = String::from_utf8_lossy(&stderr);
|
|
let stdout_text = String::from_utf8_lossy(&stdout);
|
|
let details = if !stderr_text.trim().is_empty() {
|
|
stderr_text.trim()
|
|
} else if !stdout_text.trim().is_empty() {
|
|
stdout_text.trim()
|
|
} else {
|
|
"unknown error"
|
|
};
|
|
|
|
Err(format!("{context}: {details}"))
|
|
}
|
|
|
|
fn run_git_at<I, S>(path: &Path, args: I, context: &str) -> Result<Vec<u8>, String>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(path)
|
|
.args(args)
|
|
.output()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if output.status.success() {
|
|
return Ok(output.stdout);
|
|
}
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let details = if !stderr.trim().is_empty() {
|
|
stderr.trim()
|
|
} else if !stdout.trim().is_empty() {
|
|
stdout.trim()
|
|
} else {
|
|
"unknown error"
|
|
};
|
|
|
|
Err(format!("{context}: {details}"))
|
|
}
|
|
|
|
fn run_git_with_stdin<I, S>(repo: &Path, args: I, stdin_data: &[u8]) -> Result<Vec<u8>, String>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
use std::io::Write;
|
|
|
|
let mut child = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
|
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
stdin
|
|
.write_all(stdin_data)
|
|
.map_err(|err| format!("Could not send input to Git: {err}"))?;
|
|
}
|
|
|
|
let output = child
|
|
.wait_with_output()
|
|
.map_err(|err| format!("Could not read Git output: {err}"))?;
|
|
|
|
if output.status.success() {
|
|
return Ok(output.stdout);
|
|
}
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let details = if !stderr.trim().is_empty() {
|
|
stderr.trim()
|
|
} else if !stdout.trim().is_empty() {
|
|
stdout.trim()
|
|
} else {
|
|
"unknown error"
|
|
};
|
|
|
|
Err(format!("Git command failed: {details}"))
|
|
}
|
|
|
|
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> {
|
|
let entries: Vec<&[u8]> = output
|
|
.split(|byte| *byte == 0)
|
|
.filter(|entry| !entry.is_empty())
|
|
.collect();
|
|
|
|
let mut index = 0;
|
|
let mut branch = BranchInfo::default();
|
|
|
|
if let Some(first) = entries.first() {
|
|
if first.starts_with(b"## ") {
|
|
branch = parse_branch_header(&String::from_utf8_lossy(first));
|
|
index = 1;
|
|
}
|
|
}
|
|
|
|
let mut files = Vec::new();
|
|
while index < entries.len() {
|
|
let entry = String::from_utf8_lossy(entries[index]);
|
|
index += 1;
|
|
|
|
if entry.len() < 4 {
|
|
return Err(format!("Unerwartete Git-Statuszeile: {entry}"));
|
|
}
|
|
|
|
let bytes = entry.as_bytes();
|
|
let staged_code = bytes[0] as char;
|
|
let unstaged_code = bytes[1] as char;
|
|
let path = entry[3..].to_string();
|
|
|
|
let old_path = if is_rename_or_copy(staged_code) || is_rename_or_copy(unstaged_code) {
|
|
if index >= entries.len() {
|
|
return Err(format!("Git-Status fuer Rename ohne Ursprungspfad: {path}"));
|
|
}
|
|
let old_path = String::from_utf8_lossy(entries[index]).to_string();
|
|
index += 1;
|
|
Some(old_path)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (staged, unstaged) = map_status_codes(staged_code, unstaged_code);
|
|
files.push(GitFileStatus {
|
|
path,
|
|
old_path,
|
|
staged,
|
|
unstaged,
|
|
});
|
|
}
|
|
|
|
Ok((branch, files))
|
|
}
|
|
|
|
fn parse_branch_header(header: &str) -> BranchInfo {
|
|
let mut info = BranchInfo::default();
|
|
let header = header.trim().strip_prefix("## ").unwrap_or(header).trim();
|
|
|
|
let (branch_part, tracking_part) = if let Some(start) = header.rfind(" [") {
|
|
if header.ends_with(']') {
|
|
(&header[..start], Some(&header[start + 2..header.len() - 1]))
|
|
} else {
|
|
(header, None)
|
|
}
|
|
} else {
|
|
(header, None)
|
|
};
|
|
|
|
if let Some(tracking) = tracking_part {
|
|
for item in tracking.split(',').map(str::trim) {
|
|
if let Some(value) = item.strip_prefix("ahead ") {
|
|
info.ahead = value.parse().unwrap_or(0);
|
|
} else if let Some(value) = item.strip_prefix("behind ") {
|
|
info.behind = value.parse().unwrap_or(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(branch_name) = branch_part.strip_prefix("No commits yet on ") {
|
|
info.current_branch = Some(branch_name.to_string());
|
|
return info;
|
|
}
|
|
|
|
if branch_part == "HEAD (no branch)" {
|
|
return info;
|
|
}
|
|
|
|
if let Some((current, upstream)) = branch_part.split_once("...") {
|
|
if !current.is_empty() {
|
|
info.current_branch = Some(current.to_string());
|
|
}
|
|
if !upstream.is_empty() {
|
|
info.upstream = Some(upstream.to_string());
|
|
}
|
|
} else if !branch_part.is_empty() {
|
|
info.current_branch = Some(branch_part.to_string());
|
|
}
|
|
|
|
info
|
|
}
|
|
|
|
fn map_status_codes(
|
|
staged_code: char,
|
|
unstaged_code: char,
|
|
) -> (Option<FileStatusKind>, Option<FileStatusKind>) {
|
|
if is_conflict(staged_code, unstaged_code) {
|
|
return (
|
|
Some(FileStatusKind::Conflicted),
|
|
Some(FileStatusKind::Conflicted),
|
|
);
|
|
}
|
|
|
|
if staged_code == '?' && unstaged_code == '?' {
|
|
return (None, Some(FileStatusKind::Untracked));
|
|
}
|
|
|
|
(
|
|
map_index_status(staged_code),
|
|
map_worktree_status(unstaged_code),
|
|
)
|
|
}
|
|
|
|
fn map_index_status(code: char) -> Option<FileStatusKind> {
|
|
match code {
|
|
' ' => None,
|
|
'M' => Some(FileStatusKind::Modified),
|
|
'A' => Some(FileStatusKind::Added),
|
|
'D' => Some(FileStatusKind::Deleted),
|
|
'R' => Some(FileStatusKind::Renamed),
|
|
'C' => Some(FileStatusKind::Added),
|
|
_ => Some(FileStatusKind::Unknown),
|
|
}
|
|
}
|
|
|
|
fn map_worktree_status(code: char) -> Option<FileStatusKind> {
|
|
match code {
|
|
' ' => None,
|
|
'M' => Some(FileStatusKind::Modified),
|
|
'A' => Some(FileStatusKind::Added),
|
|
'D' => Some(FileStatusKind::Deleted),
|
|
'R' => Some(FileStatusKind::Renamed),
|
|
'C' => Some(FileStatusKind::Added),
|
|
_ => Some(FileStatusKind::Unknown),
|
|
}
|
|
}
|
|
|
|
fn is_rename_or_copy(code: char) -> bool {
|
|
matches!(code, 'R' | 'C')
|
|
}
|
|
|
|
fn is_conflict(staged_code: char, unstaged_code: char) -> bool {
|
|
matches!(
|
|
(staged_code, unstaged_code),
|
|
('D', 'D') | ('A', 'U') | ('U', 'D') | ('U', 'A') | ('D', 'U') | ('A', 'A') | ('U', 'U')
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::{
|
|
ffi::OsStr,
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
struct TempRepo {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl Drop for TempRepo {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
fn init_temp_repo(name: &str) -> TempRepo {
|
|
let repo = temp_dir(name);
|
|
|
|
run_git_test(&repo.path, ["init", "-q"]);
|
|
run_git_test(&repo.path, ["config", "user.email", "test@example.com"]);
|
|
run_git_test(&repo.path, ["config", "user.name", "Tester"]);
|
|
|
|
repo
|
|
}
|
|
|
|
fn init_bare_temp_repo(name: &str) -> TempRepo {
|
|
let repo = temp_dir(name);
|
|
run_git_test(&repo.path, ["init", "--bare", "-q"]);
|
|
repo
|
|
}
|
|
|
|
fn temp_dir(name: &str) -> TempRepo {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time should be after Unix epoch")
|
|
.as_nanos();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"tauri_git_lite_{name}_{}_{}",
|
|
std::process::id(),
|
|
nanos
|
|
));
|
|
|
|
fs::create_dir_all(&path).expect("temp repo directory should be created");
|
|
|
|
TempRepo { path }
|
|
}
|
|
|
|
fn run_git_test<I, S>(repo: &Path, args: I)
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.output()
|
|
.expect("git should start");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"git command failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn git_output_test<I, S>(repo: &Path, args: I) -> String
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let output = git_command()
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.output()
|
|
.expect("git should start");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"git command failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
|
}
|
|
|
|
fn commit_initial_file(repo: &Path) {
|
|
fs::write(repo.join("old.txt"), "original\n").expect("initial file should be written");
|
|
run_git_test(repo, ["add", "old.txt"]);
|
|
run_git_test(repo, ["commit", "-q", "-m", "init"]);
|
|
}
|
|
|
|
#[test]
|
|
fn branches_report_configured_upstream_and_local_only_state() {
|
|
let repo = init_temp_repo("branch_upstream_state");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(&repo.path, ["branch", "feature/local-only"]);
|
|
run_git_test(&repo.path, ["branch", "feature/tracked"]);
|
|
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
|
|
run_git_test(
|
|
&repo.path,
|
|
[
|
|
"update-ref",
|
|
"refs/remotes/origin/feature/published",
|
|
"HEAD",
|
|
],
|
|
);
|
|
run_git_test(
|
|
&repo.path,
|
|
["config", "branch.feature/tracked.remote", "origin"],
|
|
);
|
|
run_git_test(
|
|
&repo.path,
|
|
[
|
|
"config",
|
|
"branch.feature/tracked.merge",
|
|
"refs/heads/feature/published",
|
|
],
|
|
);
|
|
|
|
let branches = branches_for_repo(&repo.path).expect("branches should load");
|
|
let local_only = branches
|
|
.iter()
|
|
.find(|branch| branch.name == "feature/local-only")
|
|
.expect("local-only branch should exist");
|
|
let tracked = branches
|
|
.iter()
|
|
.find(|branch| branch.name == "feature/tracked")
|
|
.expect("tracked branch should exist");
|
|
let remote = branches
|
|
.iter()
|
|
.find(|branch| branch.name == "origin/feature/published")
|
|
.expect("remote branch should exist");
|
|
|
|
assert_eq!(local_only.upstream, None);
|
|
assert_eq!(
|
|
tracked.upstream.as_deref(),
|
|
Some("origin/feature/published")
|
|
);
|
|
assert_eq!(remote.upstream, None);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() {
|
|
let repo = init_temp_repo("commit_notes_crud");
|
|
commit_initial_file(&repo.path);
|
|
let commit_before = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
assert_eq!(
|
|
commit_note_for_repo(&repo.path, &commit_before).expect("note lookup should work"),
|
|
None
|
|
);
|
|
|
|
set_commit_note_for_repo(
|
|
&repo.path,
|
|
&commit_before,
|
|
"Review: sieht gut aus\nBuild: 42",
|
|
)
|
|
.expect("note should be created");
|
|
assert_eq!(
|
|
commit_note_for_repo(&repo.path, &commit_before).expect("note should load"),
|
|
Some("Review: sieht gut aus\nBuild: 42".to_string())
|
|
);
|
|
|
|
set_commit_note_for_repo(&repo.path, &commit_before, "Freigabe erteilt")
|
|
.expect("note should be replaced");
|
|
assert_eq!(
|
|
commit_note_for_repo(&repo.path, &commit_before).expect("updated note should load"),
|
|
Some("Freigabe erteilt".to_string())
|
|
);
|
|
|
|
delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted");
|
|
assert_eq!(
|
|
commit_note_for_repo(&repo.path, &commit_before)
|
|
.expect("deleted note lookup should work"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
git_output_test(&repo.path, ["rev-parse", "HEAD"]),
|
|
commit_before
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg_attr(
|
|
windows,
|
|
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
|
)]
|
|
fn commit_notes_can_be_pushed_and_fetched_through_the_notes_ref() {
|
|
let source = init_temp_repo("commit_notes_source");
|
|
let target = init_temp_repo("commit_notes_target");
|
|
let remote = init_bare_temp_repo("commit_notes_remote");
|
|
commit_initial_file(&source.path);
|
|
let commit = git_output_test(&source.path, ["rev-parse", "HEAD"]);
|
|
let remote_url = format!(
|
|
"file:///{}",
|
|
remote.path.to_string_lossy().replace('\\', "/")
|
|
);
|
|
|
|
run_git_test(
|
|
&source.path,
|
|
["remote", "add", "origin", remote_url.as_str()],
|
|
);
|
|
run_git_test(
|
|
&source.path,
|
|
["push", "-q", "origin", "HEAD:refs/heads/main"],
|
|
);
|
|
set_commit_note_for_repo(&source.path, &commit, "Shared review note")
|
|
.expect("source note should be created");
|
|
push_commit_notes_for_repo(&source.path, "origin", None, None)
|
|
.expect("notes should be pushed");
|
|
|
|
run_git_test(
|
|
&target.path,
|
|
["remote", "add", "origin", remote_url.as_str()],
|
|
);
|
|
run_git_test(&target.path, ["fetch", "-q", "origin", "main"]);
|
|
run_git_test(&target.path, ["checkout", "-q", "FETCH_HEAD"]);
|
|
fetch_commit_notes_for_repo(&target.path, "origin", None, None)
|
|
.expect("notes should be fetched");
|
|
|
|
assert_eq!(
|
|
commit_note_for_repo(&target.path, &commit).expect("fetched note should load"),
|
|
Some("Shared review note".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clone_directory_name_is_inferred_from_common_remote_urls() {
|
|
assert_eq!(
|
|
infer_clone_directory_name("https://github.com/example/project.git"),
|
|
"project"
|
|
);
|
|
assert_eq!(
|
|
infer_clone_directory_name("git@github.com:example/project.git"),
|
|
"project"
|
|
);
|
|
assert_eq!(
|
|
infer_clone_directory_name("ssh://git@example.com/example/project.git/"),
|
|
"project"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clone_repository_core_clones_and_returns_repository_bundle() {
|
|
let source = init_temp_repo("clone_source");
|
|
commit_initial_file(&source.path);
|
|
let parent = temp_dir("clone_parent");
|
|
|
|
let bundle = clone_repository_core(
|
|
source.path.to_str().expect("source path should be UTF-8"),
|
|
parent.path.to_str().expect("parent path should be UTF-8"),
|
|
Some("local-copy"),
|
|
None,
|
|
None,
|
|
Some(100),
|
|
)
|
|
.expect("repository should clone");
|
|
|
|
let cloned_repo = parent.path.join("local-copy");
|
|
assert_eq!(
|
|
PathBuf::from(bundle.status.repo_path),
|
|
cloned_repo
|
|
.canonicalize()
|
|
.expect("clone path should resolve")
|
|
);
|
|
assert!(cloned_repo.join("old.txt").exists());
|
|
assert!(bundle.status.clean);
|
|
assert_eq!(bundle.commits.len(), 1);
|
|
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_code_introductions_finds_added_string() {
|
|
let repo = init_temp_repo("search_added_string");
|
|
fs::create_dir_all(repo.path.join("src")).expect("src directory should be created");
|
|
fs::write(repo.path.join("src/app.ts"), "export const existing = 1;\n")
|
|
.expect("initial source file should be written");
|
|
run_git_test(&repo.path, ["add", "src/app.ts"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "initial source"]);
|
|
|
|
fs::write(
|
|
repo.path.join("src/app.ts"),
|
|
"export const existing = 1;\n\nexport function renderWidget() {\n return \"needle-token\";\n}\n",
|
|
)
|
|
.expect("updated source file should be written");
|
|
run_git_test(&repo.path, ["add", "src/app.ts"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "add render widget"]);
|
|
|
|
let hits =
|
|
search_code_introductions_core(&repo.path, "renderWidget".to_string(), false, 20, None)
|
|
.expect("search should succeed");
|
|
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].file, "src/app.ts");
|
|
assert_eq!(hits[0].summary, "add render widget");
|
|
assert_eq!(hits[0].line_number, Some(3));
|
|
}
|
|
|
|
#[test]
|
|
fn search_code_introductions_finds_multiline_function_block() {
|
|
let repo = init_temp_repo("search_multiline_function");
|
|
fs::write(repo.path.join("module.ts"), "export const ready = true;\n")
|
|
.expect("initial module should be written");
|
|
run_git_test(&repo.path, ["add", "module.ts"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "initial module"]);
|
|
|
|
let function_body = "export function parseThing() {\n return \"needle-token\";\n}";
|
|
fs::write(
|
|
repo.path.join("module.ts"),
|
|
format!("export const ready = true;\n\n{function_body}\n"),
|
|
)
|
|
.expect("updated module should be written");
|
|
run_git_test(&repo.path, ["add", "module.ts"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "add parser"]);
|
|
|
|
let hits =
|
|
search_code_introductions_core(&repo.path, function_body.to_string(), false, 20, None)
|
|
.expect("search should succeed");
|
|
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].file, "module.ts");
|
|
assert_eq!(hits[0].summary, "add parser");
|
|
assert_eq!(hits[0].line_number, Some(3));
|
|
}
|
|
|
|
#[test]
|
|
fn search_code_introductions_can_be_cancelled() {
|
|
let repo = init_temp_repo("search_cancelled");
|
|
fs::write(
|
|
repo.path.join("module.ts"),
|
|
"export const value = \"needle\";\n",
|
|
)
|
|
.expect("module should be written");
|
|
run_git_test(&repo.path, ["add", "module.ts"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "add module"]);
|
|
|
|
let state = SearchCancellationState::default();
|
|
state
|
|
.cancel("test-search")
|
|
.expect("cancel flag should be set");
|
|
let result = search_code_introductions_core(
|
|
&repo.path,
|
|
"needle".to_string(),
|
|
false,
|
|
20,
|
|
Some(&SearchCancellation {
|
|
state: state.clone(),
|
|
search_id: "test-search".to_string(),
|
|
}),
|
|
);
|
|
|
|
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_branch_tracking_and_file_states() {
|
|
let raw = b"## main...origin/main [ahead 2, behind 1]\0 M changed.txt\0D deleted.txt\0?? new.txt\0";
|
|
let (branch, files) = parse_status_output(raw).unwrap();
|
|
|
|
assert_eq!(branch.current_branch.as_deref(), Some("main"));
|
|
assert_eq!(branch.upstream.as_deref(), Some("origin/main"));
|
|
assert_eq!(branch.ahead, 2);
|
|
assert_eq!(branch.behind, 1);
|
|
assert_eq!(
|
|
files,
|
|
vec![
|
|
GitFileStatus {
|
|
path: "changed.txt".to_string(),
|
|
old_path: None,
|
|
staged: None,
|
|
unstaged: Some(FileStatusKind::Modified),
|
|
},
|
|
GitFileStatus {
|
|
path: "deleted.txt".to_string(),
|
|
old_path: None,
|
|
staged: Some(FileStatusKind::Deleted),
|
|
unstaged: None,
|
|
},
|
|
GitFileStatus {
|
|
path: "new.txt".to_string(),
|
|
old_path: None,
|
|
staged: None,
|
|
unstaged: Some(FileStatusKind::Untracked),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_z_renames_with_current_path_first() {
|
|
let raw = b"## feature\0R new-name.txt\0old-name.txt\0";
|
|
let (_, files) = parse_status_output(raw).unwrap();
|
|
|
|
assert_eq!(
|
|
files,
|
|
vec![GitFileStatus {
|
|
path: "new-name.txt".to_string(),
|
|
old_path: Some("old-name.txt".to_string()),
|
|
staged: Some(FileStatusKind::Renamed),
|
|
unstaged: None,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_conflicts_as_conflicted() {
|
|
let raw = b"## main\0UU conflicted.txt\0";
|
|
let (_, files) = parse_status_output(raw).unwrap();
|
|
|
|
assert_eq!(files[0].staged, Some(FileStatusKind::Conflicted));
|
|
assert_eq!(files[0].unstaged, Some(FileStatusKind::Conflicted));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_initial_branch_header() {
|
|
let branch = parse_branch_header("## No commits yet on main");
|
|
|
|
assert_eq!(branch.current_branch.as_deref(), Some("main"));
|
|
assert_eq!(branch.upstream, None);
|
|
assert_eq!(branch.ahead, 0);
|
|
assert_eq!(branch.behind, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_reflog_records() {
|
|
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fHEAD@{0}\x1fcommit: Add feature\x1fAda\x1f2026-07-10T12:00:00+02:00\x1e";
|
|
let entries = parse_reflog(raw).expect("reflog should parse");
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].selector, "HEAD@{0}");
|
|
assert_eq!(entries[0].action, "commit: Add feature");
|
|
}
|
|
|
|
#[test]
|
|
fn commit_editor_applies_only_the_matching_reword_message() {
|
|
let temp = temp_dir("commit_editor");
|
|
let queue = temp.path.join("queue");
|
|
let message = temp.path.join("COMMIT_EDITMSG");
|
|
fs::write(
|
|
&queue,
|
|
"first commit\x1frenamed first\x1esecond commit\x1frenamed second\x1e",
|
|
)
|
|
.expect("queue should be written");
|
|
fs::write(
|
|
&message,
|
|
"first commit\n\n# Please enter the commit message\n",
|
|
)
|
|
.expect("message should be written");
|
|
|
|
apply_reword_message(&queue, &message).expect("message should be applied");
|
|
|
|
assert_eq!(fs::read_to_string(&message).unwrap(), "renamed first\n");
|
|
assert_eq!(
|
|
fs::read_to_string(&queue).unwrap(),
|
|
"second commit\x1frenamed second\x1e"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interactive_rebase_builds_a_valid_reword_and_squash_plan() {
|
|
let repo = init_temp_repo("interactive_rebase");
|
|
commit_initial_file(&repo.path);
|
|
let base = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(repo.path.join("first.txt"), "first\n").expect("first file should be written");
|
|
run_git_test(&repo.path, ["add", "first.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "first commit"]);
|
|
fs::write(repo.path.join("second.txt"), "second\n").expect("second file should be written");
|
|
run_git_test(&repo.path, ["add", "second.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "second commit"]);
|
|
|
|
let commits = interactive_rebase_commits_for_repo(&repo.path, &base)
|
|
.expect("rebase commits should load");
|
|
assert_eq!(commits.len(), 2);
|
|
let plan = vec![
|
|
RebasePlanItem {
|
|
hash: commits[0].hash.clone(),
|
|
action: RebaseAction::Reword,
|
|
message: Some("combined feature".to_string()),
|
|
},
|
|
RebasePlanItem {
|
|
hash: commits[1].hash.clone(),
|
|
action: RebaseAction::Squash,
|
|
message: None,
|
|
},
|
|
];
|
|
|
|
validate_rebase_plan(&commits, &plan).expect("plan should be valid");
|
|
let todo = build_rebase_todo(&commits, &plan).expect("todo should build");
|
|
|
|
assert!(todo.contains(&format!("reword {} first commit", commits[0].hash)));
|
|
assert!(todo.contains(&format!("squash {} second commit", commits[1].hash)));
|
|
assert_eq!(
|
|
build_reword_queue(&commits, &plan).expect("queue should build"),
|
|
"first commit\x1fcombined feature\x1e"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interactive_rebase_accepts_a_diverged_base_branch() {
|
|
let repo = init_temp_repo("interactive_rebase_diverged");
|
|
commit_initial_file(&repo.path);
|
|
let main_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
|
|
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
|
|
fs::write(repo.path.join("feature.txt"), "feature\n")
|
|
.expect("feature file should be written");
|
|
run_git_test(&repo.path, ["add", "feature.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "feature commit"]);
|
|
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
|
|
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
|
|
run_git_test(&repo.path, ["add", "main.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "main advanced"]);
|
|
run_git_test(&repo.path, ["checkout", "-q", "feature"]);
|
|
|
|
let commits = interactive_rebase_commits_for_repo(&repo.path, &main_branch)
|
|
.expect("diverged base should be accepted");
|
|
|
|
assert_eq!(commits.len(), 1);
|
|
assert_eq!(commits[0].summary, "feature commit");
|
|
}
|
|
|
|
#[test]
|
|
fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() {
|
|
let repo = init_temp_repo("reflog_restore");
|
|
commit_initial_file(&repo.path);
|
|
let initial = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
let original_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
|
|
|
|
fs::write(repo.path.join("later.txt"), "later\n").expect("later file should be written");
|
|
run_git_test(&repo.path, ["add", "later.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "later"]);
|
|
|
|
let entries = list_reflog(repo.path.to_string_lossy().to_string(), Some(20))
|
|
.expect("reflog should load");
|
|
assert!(entries.len() >= 2);
|
|
|
|
let status = restore_reflog_entry(
|
|
repo.path.to_string_lossy().to_string(),
|
|
initial.clone(),
|
|
"recovery/initial".to_string(),
|
|
)
|
|
.expect("recovery branch should be created");
|
|
|
|
assert_eq!(status.current_branch.as_deref(), Some("recovery/initial"));
|
|
assert_eq!(git_output_test(&repo.path, ["rev-parse", "HEAD"]), initial);
|
|
assert!(
|
|
ref_exists(&repo.path, &format!("refs/heads/{original_branch}"))
|
|
.expect("original branch should still exist")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_commit_history_records() {
|
|
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e";
|
|
let commits = parse_commit_log_metadata(raw).unwrap();
|
|
|
|
assert_eq!(
|
|
commits,
|
|
vec![GitCommit {
|
|
hash: "1111111111111111111111111111111111111111".to_string(),
|
|
short_hash: "1111111".to_string(),
|
|
author_name: "Ada Lovelace".to_string(),
|
|
author_email: "ada@example.com".to_string(),
|
|
date: "2026-06-26T12:34:56+02:00".to_string(),
|
|
refs: vec!["HEAD -> main".to_string(), "tag: v1".to_string()],
|
|
parents: vec![
|
|
"2222222222222222222222222222222222222222".to_string(),
|
|
"3333333333333333333333333333333333333333".to_string(),
|
|
],
|
|
summary: "Add history panel".to_string(),
|
|
files: Vec::new(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_commit_file_changes_and_renames() {
|
|
let raw = b"M\0src/main.rs\0A\0README.md\0R100\0old.txt\0new.txt\0";
|
|
let files = parse_commit_files(raw).unwrap();
|
|
|
|
assert_eq!(
|
|
files,
|
|
vec![
|
|
GitCommitFile {
|
|
path: "src/main.rs".to_string(),
|
|
old_path: None,
|
|
status: FileStatusKind::Modified,
|
|
},
|
|
GitCommitFile {
|
|
path: "README.md".to_string(),
|
|
old_path: None,
|
|
status: FileStatusKind::Added,
|
|
},
|
|
GitCommitFile {
|
|
path: "new.txt".to_string(),
|
|
old_path: Some("old.txt".to_string()),
|
|
status: FileStatusKind::Renamed,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_diff_files_merges_name_status_with_numstat_counts() {
|
|
let name_status = b"M\0src/main.rs\0A\0README.md\0R100\0old.txt\0new.txt\0";
|
|
let numstat = b"4\t2\tsrc/main.rs\010\t0\tREADME.md\00\t0\t\0old.txt\0new.txt\0";
|
|
|
|
let files = parse_diff_files(name_status, numstat).unwrap();
|
|
|
|
assert_eq!(
|
|
files,
|
|
vec![
|
|
GitDiffFile {
|
|
path: "src/main.rs".to_string(),
|
|
old_path: None,
|
|
status: FileStatusKind::Modified,
|
|
additions: 4,
|
|
deletions: 2,
|
|
},
|
|
GitDiffFile {
|
|
path: "README.md".to_string(),
|
|
old_path: None,
|
|
status: FileStatusKind::Added,
|
|
additions: 10,
|
|
deletions: 0,
|
|
},
|
|
GitDiffFile {
|
|
path: "new.txt".to_string(),
|
|
old_path: Some("old.txt".to_string()),
|
|
status: FileStatusKind::Renamed,
|
|
additions: 0,
|
|
deletions: 0,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_numstat_treats_binary_dashes_as_zero() {
|
|
let counts = parse_numstat_z(b"-\t-\tlogo.png\0");
|
|
|
|
assert_eq!(counts, vec![("logo.png".to_string(), 0, 0)]);
|
|
}
|
|
|
|
#[test]
|
|
fn compare_commits_reports_changes_between_two_commits() {
|
|
let repo = init_temp_repo("compare_commits");
|
|
commit_initial_file(&repo.path);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(repo.path.join("old.txt"), "original\nsecond line\n")
|
|
.expect("tracked file should change");
|
|
fs::write(repo.path.join("added.txt"), "brand new\n")
|
|
.expect("added file should be written");
|
|
run_git_test(&repo.path, ["add", "old.txt", "added.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
|
let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
let comparison = compare_commits(
|
|
repo.path.to_string_lossy().to_string(),
|
|
first_commit,
|
|
second_commit,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
comparison
|
|
.files
|
|
.iter()
|
|
.any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified)
|
|
);
|
|
assert!(
|
|
comparison
|
|
.files
|
|
.iter()
|
|
.any(|file| file.path == "added.txt" && file.status == FileStatusKind::Added)
|
|
);
|
|
assert!(comparison.patch.contains("second line"));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_commits_accepts_branch_refs_for_a_full_repository_diff() {
|
|
let repo = init_temp_repo("compare_branches");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(&repo.path, ["branch", "base"]);
|
|
|
|
fs::write(repo.path.join("branch-only.txt"), "only on feature\n")
|
|
.expect("branch file should be written");
|
|
run_git_test(&repo.path, ["add", "branch-only.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "feature change"]);
|
|
run_git_test(&repo.path, ["branch", "feature/complete-compare"]);
|
|
|
|
let comparison = compare_commits(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"refs/heads/base".to_string(),
|
|
"refs/heads/feature/complete-compare".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(comparison.files.iter().any(|file| {
|
|
file.path == "branch-only.txt" && file.status == FileStatusKind::Added
|
|
}));
|
|
assert!(comparison.patch.contains("only on feature"));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_commits_includes_full_file_context() {
|
|
let repo = init_temp_repo("compare_full_context");
|
|
let before = (1..=60)
|
|
.map(|line| format!("line {line}\n"))
|
|
.collect::<String>();
|
|
|
|
fs::write(repo.path.join("context.txt"), &before).expect("context file should be written");
|
|
run_git_test(&repo.path, ["add", "context.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(
|
|
repo.path.join("context.txt"),
|
|
before.replace("line 30\n", "line 30 changed\n"),
|
|
)
|
|
.expect("context file should be changed");
|
|
run_git_test(&repo.path, ["add", "context.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "change"]);
|
|
let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
let comparison = compare_commits(
|
|
repo.path.to_string_lossy().to_string(),
|
|
first_commit,
|
|
second_commit,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(comparison.patch.contains(" line 1\n"));
|
|
assert!(comparison.patch.contains(" line 60\n"));
|
|
assert!(comparison.patch.contains("-line 30\n"));
|
|
assert!(comparison.patch.contains("+line 30 changed\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_file_against_working_tree_reports_uncommitted_changes() {
|
|
let repo = init_temp_repo("diff_against_working_tree");
|
|
commit_initial_file(&repo.path);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
// Change the file on disk without committing.
|
|
fs::write(repo.path.join("old.txt"), "working tree change\n")
|
|
.expect("working tree file should change");
|
|
|
|
let comparison = diff_file_against_working_tree(
|
|
repo.path.to_string_lossy().to_string(),
|
|
first_commit,
|
|
"old.txt".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(comparison.to_short, "working tree");
|
|
assert!(comparison.to_hash.is_empty());
|
|
assert!(
|
|
comparison
|
|
.files
|
|
.iter()
|
|
.any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified)
|
|
);
|
|
assert!(comparison.patch.contains("working tree change"));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_file_to_head_reports_selected_file_against_current_commit() {
|
|
let repo = init_temp_repo("compare_file_to_head");
|
|
commit_initial_file(&repo.path);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(repo.path.join("old.txt"), "original\nsecond line\n")
|
|
.expect("tracked file should change");
|
|
fs::write(repo.path.join("other.txt"), "other file\n")
|
|
.expect("other file should be written");
|
|
run_git_test(&repo.path, ["add", "old.txt", "other.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
|
let head_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
let comparison = compare_file_to_head(
|
|
repo.path.to_string_lossy().to_string(),
|
|
first_commit,
|
|
"old.txt".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(comparison.to_hash, head_commit);
|
|
assert_eq!(comparison.to_short, "HEAD");
|
|
assert_eq!(comparison.files.len(), 1);
|
|
assert_eq!(comparison.files[0].path, "old.txt");
|
|
assert!(comparison.patch.contains("second line"));
|
|
assert!(!comparison.patch.contains("other file"));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_file_to_parent_reports_selected_file_change_in_commit() {
|
|
let repo = init_temp_repo("compare_file_to_parent");
|
|
commit_initial_file(&repo.path);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(repo.path.join("old.txt"), "original\nsecond line\n")
|
|
.expect("tracked file should change");
|
|
fs::write(repo.path.join("other.txt"), "other file\n")
|
|
.expect("other file should be written");
|
|
run_git_test(&repo.path, ["add", "old.txt", "other.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
|
let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
let comparison = compare_file_to_parent(
|
|
repo.path.to_string_lossy().to_string(),
|
|
second_commit.clone(),
|
|
"old.txt".to_string(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(comparison.from_hash, first_commit);
|
|
assert_eq!(comparison.to_hash, second_commit);
|
|
assert_eq!(comparison.files.len(), 1);
|
|
assert_eq!(comparison.files[0].path, "old.txt");
|
|
assert!(comparison.patch.contains("second line"));
|
|
assert!(!comparison.patch.contains("other file"));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_file_to_parent_uses_empty_tree_for_initial_commit() {
|
|
let repo = init_temp_repo("compare_file_to_parent_initial");
|
|
commit_initial_file(&repo.path);
|
|
let initial_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
let comparison = compare_file_to_parent(
|
|
repo.path.to_string_lossy().to_string(),
|
|
initial_commit.clone(),
|
|
"old.txt".to_string(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(comparison.from_hash, EMPTY_TREE_HASH);
|
|
assert_eq!(comparison.from_short, "empty tree");
|
|
assert_eq!(comparison.to_hash, initial_commit);
|
|
assert_eq!(comparison.files.len(), 1);
|
|
assert_eq!(comparison.files[0].path, "old.txt");
|
|
assert!(comparison.patch.contains("+original"));
|
|
}
|
|
|
|
#[test]
|
|
fn commits_for_repo_includes_all_branch_tips_for_graph() {
|
|
let repo = init_temp_repo("commits_all_branches");
|
|
commit_initial_file(&repo.path);
|
|
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/graph"]);
|
|
fs::write(repo.path.join("feature.txt"), "feature\n")
|
|
.expect("feature file should be written");
|
|
run_git_test(&repo.path, ["add", "feature.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "feature graph"]);
|
|
let feature_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
|
|
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
|
|
run_git_test(&repo.path, ["add", "main.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "main graph"]);
|
|
let main_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
let commits = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
|
|
|
|
assert!(commits.iter().any(|commit| commit.hash == main_commit));
|
|
assert!(commits.iter().any(|commit| {
|
|
commit.hash == feature_commit && commit.refs.iter().any(|r| r.contains("feature/graph"))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn commit_pages_use_stable_non_overlapping_offsets() {
|
|
let repo = init_temp_repo("commit_pages");
|
|
commit_initial_file(&repo.path);
|
|
|
|
for index in 1..=3 {
|
|
fs::write(repo.path.join("old.txt"), format!("version {index}\n"))
|
|
.expect("tracked file should change");
|
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
|
run_git_test(
|
|
&repo.path,
|
|
["commit", "-q", "-m", format!("commit {index}").as_str()],
|
|
);
|
|
}
|
|
|
|
let all = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
|
|
let first = commit_page_for_repo(&repo.path, Some(2), Some(0))
|
|
.expect("first commit page should load");
|
|
let second = commit_page_for_repo(&repo.path, Some(2), Some(2))
|
|
.expect("second commit page should load");
|
|
|
|
assert_eq!(first.len(), 2);
|
|
assert_eq!(second.len(), 2);
|
|
assert_eq!(first[0].hash, all[0].hash);
|
|
assert_eq!(first[1].hash, all[1].hash);
|
|
assert_eq!(second[0].hash, all[2].hash);
|
|
assert_eq!(second[1].hash, all[3].hash);
|
|
assert!(
|
|
first
|
|
.iter()
|
|
.all(|commit| second.iter().all(|other| other.hash != commit.hash))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg_attr(
|
|
windows,
|
|
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
|
|
)]
|
|
async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
|
|
let repo = init_temp_repo("pull_diverged");
|
|
commit_initial_file(&repo.path);
|
|
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", "-b", "remote-change"]);
|
|
fs::write(repo.path.join("remote.txt"), "remote change\n")
|
|
.expect("remote file should be written");
|
|
run_git_test(&repo.path, ["add", "remote.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "remote change"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
|
|
fs::write(repo.path.join("local.txt"), "local change\n")
|
|
.expect("local file should be written");
|
|
run_git_test(&repo.path, ["add", "local.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "local change"]);
|
|
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
|
|
run_git_test(
|
|
&repo.path,
|
|
["config", &format!("branch.{base_branch}.remote"), "origin"],
|
|
);
|
|
run_git_test(
|
|
&repo.path,
|
|
[
|
|
"config",
|
|
&format!("branch.{base_branch}.merge"),
|
|
"refs/heads/remote-change",
|
|
],
|
|
);
|
|
|
|
let status = pull(
|
|
repo.path.to_string_lossy().to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(status.clean, "{:?}", status.files);
|
|
assert!(repo.path.join("remote.txt").exists());
|
|
assert!(repo.path.join("local.txt").exists());
|
|
|
|
let parent_count =
|
|
git_output_test(&repo.path, ["rev-list", "--parents", "-n", "1", "HEAD"])
|
|
.split_whitespace()
|
|
.count()
|
|
- 1;
|
|
assert_eq!(parent_count, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn push_args_use_set_upstream_for_branch_without_tracking_remote() {
|
|
let repo = init_temp_repo("push_sets_upstream");
|
|
let remote = init_bare_temp_repo("push_sets_upstream_remote");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(
|
|
&repo.path,
|
|
["checkout", "-q", "-b", "features/ai_commits_local"],
|
|
);
|
|
fs::write(repo.path.join("feature.txt"), "feature\n")
|
|
.expect("feature file should be written");
|
|
run_git_test(&repo.path, ["add", "feature.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]);
|
|
run_git_test(
|
|
&repo.path,
|
|
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
|
);
|
|
|
|
let args = push_args_for_repo(&repo.path).unwrap();
|
|
let args = args
|
|
.iter()
|
|
.map(|arg| arg.to_string_lossy().to_string())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"push",
|
|
"--set-upstream",
|
|
"origin",
|
|
"features/ai_commits_local"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg_attr(
|
|
windows,
|
|
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
|
)]
|
|
async fn push_sets_upstream_for_branch_without_tracking_remote() {
|
|
let repo = init_temp_repo("push_sets_upstream_integration");
|
|
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(
|
|
&repo.path,
|
|
["checkout", "-q", "-b", "features/ai_commits_local"],
|
|
);
|
|
fs::write(repo.path.join("feature.txt"), "feature\n")
|
|
.expect("feature file should be written");
|
|
run_git_test(&repo.path, ["add", "feature.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]);
|
|
run_git_test(
|
|
&repo.path,
|
|
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
|
);
|
|
|
|
let status = push(
|
|
repo.path.to_string_lossy().to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
status.upstream.as_deref(),
|
|
Some("origin/features/ai_commits_local")
|
|
);
|
|
let local_head = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
let remote_head = git_output_test(
|
|
&remote.path,
|
|
["rev-parse", "refs/heads/features/ai_commits_local"],
|
|
);
|
|
assert_eq!(remote_head, local_head);
|
|
}
|
|
|
|
#[test]
|
|
fn read_and_resolve_conflict_round_trip() {
|
|
let repo = init_temp_repo("resolve_conflict");
|
|
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
|
|
run_git_test(&repo.path, ["add", "file.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
|
|
let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
|
|
fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change");
|
|
run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
|
|
fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change");
|
|
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
|
|
|
// The merge is expected to fail with a conflict, so run git directly.
|
|
let _ = git_command()
|
|
.arg("-C")
|
|
.arg(&repo.path)
|
|
.args(["merge", "--no-edit", "feature"])
|
|
.output()
|
|
.expect("git merge should start");
|
|
|
|
let conflict = read_conflict(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"file.txt".to_string(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
conflict.ours.unwrap().replace("\r\n", "\n"),
|
|
"ours change\n"
|
|
);
|
|
assert_eq!(
|
|
conflict.theirs.unwrap().replace("\r\n", "\n"),
|
|
"theirs change\n"
|
|
);
|
|
assert!(conflict.content.contains("<<<<<<<"));
|
|
|
|
let status = resolve_conflict(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"file.txt".to_string(),
|
|
"resolved\n".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(!status.files.iter().any(|file| {
|
|
matches!(file.staged, Some(FileStatusKind::Conflicted))
|
|
|| matches!(file.unstaged, Some(FileStatusKind::Conflicted))
|
|
}));
|
|
let contents = fs::read_to_string(repo.path.join("file.txt")).unwrap();
|
|
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn commit_rejects_unresolved_merge_conflicts() {
|
|
let repo = init_temp_repo("commit_rejects_conflicts");
|
|
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
|
|
run_git_test(&repo.path, ["add", "file.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
|
|
let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
|
|
fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change");
|
|
run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
|
|
fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change");
|
|
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
|
|
|
|
let _ = git_command()
|
|
.arg("-C")
|
|
.arg(&repo.path)
|
|
.args(["merge", "--no-edit", "feature"])
|
|
.output()
|
|
.expect("git merge should start");
|
|
|
|
let err = commit(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"should not commit".to_string(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.contains("Merge conflicts"));
|
|
let status = status_for_repo(&repo.path).unwrap();
|
|
assert!(has_unresolved_conflicts(&status));
|
|
}
|
|
|
|
#[test]
|
|
fn detects_binary_content_by_nul_byte() {
|
|
assert!(is_binary_bytes(&[0u8, 1, 2, 3]));
|
|
assert!(!is_binary_bytes(b"plain text content\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn binary_conflict_can_be_resolved_by_side() {
|
|
let repo = init_temp_repo("binary_conflict");
|
|
fs::write(repo.path.join("img.bin"), [0u8, 1, 2, 3])
|
|
.expect("base binary should be written");
|
|
run_git_test(&repo.path, ["add", "img.bin"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
|
|
let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
|
|
fs::write(repo.path.join("img.bin"), [0u8, 9, 9, 9, 9, 9]).expect("feature binary");
|
|
run_git_test(&repo.path, ["commit", "-q", "-am", "feature bin"]);
|
|
|
|
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
|
|
fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary");
|
|
run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]);
|
|
|
|
let _ = git_command()
|
|
.arg("-C")
|
|
.arg(&repo.path)
|
|
.args(["merge", "--no-edit", "feature"])
|
|
.output()
|
|
.expect("git merge should start");
|
|
|
|
let conflict = read_conflict(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"img.bin".to_string(),
|
|
)
|
|
.unwrap();
|
|
assert!(conflict.binary);
|
|
assert!(conflict.content.is_empty());
|
|
assert_eq!(conflict.ours_size, Some(3));
|
|
assert_eq!(conflict.theirs_size, Some(6));
|
|
|
|
let status = resolve_conflict_side(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"img.bin".to_string(),
|
|
"ours".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(!status.files.iter().any(|file| {
|
|
matches!(file.staged, Some(FileStatusKind::Conflicted))
|
|
|| matches!(file.unstaged, Some(FileStatusKind::Conflicted))
|
|
}));
|
|
let bytes = fs::read(repo.path.join("img.bin")).unwrap();
|
|
assert_eq!(bytes, vec![0u8, 7, 7]);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_staged_added_file_removes_it_from_index_and_worktree() {
|
|
let repo = init_temp_repo("restore_staged_added_file");
|
|
commit_initial_file(&repo.path);
|
|
fs::write(repo.path.join("new.txt"), "new\n").expect("added file should be written");
|
|
run_git_test(&repo.path, ["add", "new.txt"]);
|
|
|
|
let status = status_for_repo(&repo.path).unwrap();
|
|
assert_eq!(status.files[0].path, "new.txt");
|
|
assert_eq!(status.files[0].staged, Some(FileStatusKind::Added));
|
|
|
|
restore_staged_files(&repo.path, &status.files, &["new.txt".to_string()]).unwrap();
|
|
|
|
let next_status = status_for_repo(&repo.path).unwrap();
|
|
assert!(next_status.clean, "{:?}", next_status.files);
|
|
assert!(!repo.path.join("new.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn restore_staged_rename_restores_old_path_and_removes_new_path() {
|
|
let repo = init_temp_repo("restore_staged_rename");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(&repo.path, ["mv", "old.txt", "new.txt"]);
|
|
|
|
let status = status_for_repo(&repo.path).unwrap();
|
|
assert_eq!(status.files[0].path, "new.txt");
|
|
assert_eq!(status.files[0].old_path.as_deref(), Some("old.txt"));
|
|
assert_eq!(status.files[0].staged, Some(FileStatusKind::Renamed));
|
|
|
|
restore_staged_files(&repo.path, &status.files, &["new.txt".to_string()]).unwrap();
|
|
|
|
let next_status = status_for_repo(&repo.path).unwrap();
|
|
assert!(next_status.clean, "{:?}", next_status.files);
|
|
assert!(repo.path.join("old.txt").exists());
|
|
assert!(!repo.path.join("new.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn plans_remote_branch_checkout_as_tracking_branch() {
|
|
let repo = init_temp_repo("remote_checkout_plan");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(
|
|
&repo.path,
|
|
["update-ref", "refs/remotes/origin/feature/demo", "HEAD"],
|
|
);
|
|
|
|
let plan = checkout_plan(&repo.path, "origin/feature/demo").unwrap();
|
|
|
|
assert_eq!(
|
|
plan,
|
|
CheckoutPlan::TrackRemote {
|
|
local: "feature/demo".to_string(),
|
|
remote: "origin/feature/demo".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_checkout_prefers_existing_local_branch() {
|
|
let repo = init_temp_repo("remote_checkout_existing_local");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(&repo.path, ["branch", "feature/demo"]);
|
|
run_git_test(
|
|
&repo.path,
|
|
["update-ref", "refs/remotes/origin/feature/demo", "HEAD"],
|
|
);
|
|
|
|
let plan = checkout_plan(&repo.path, "origin/feature/demo").unwrap();
|
|
|
|
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn create_branch_creates_and_checks_out_local_branch() {
|
|
let repo = init_temp_repo("create_branch");
|
|
commit_initial_file(&repo.path);
|
|
|
|
let status = create_branch_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"feature/new-panel".to_string(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(status.current_branch.as_deref(), Some("feature/new-panel"));
|
|
assert!(
|
|
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
|
|
"new branch should exist"
|
|
);
|
|
|
|
let err = create_branch_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"feature/new-panel".to_string(),
|
|
None,
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.contains("already exists"));
|
|
}
|
|
|
|
#[test]
|
|
fn rename_branch_renames_existing_local_branch() {
|
|
let repo = init_temp_repo("rename_branch");
|
|
commit_initial_file(&repo.path);
|
|
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
run_git_test(&repo.path, ["branch", "feature/old-panel"]);
|
|
|
|
let status = rename_branch_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"feature/old-panel".to_string(),
|
|
"feature/new-panel".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
|
|
assert!(
|
|
!ref_exists(&repo.path, "refs/heads/feature/old-panel").unwrap(),
|
|
"old branch should be gone"
|
|
);
|
|
assert!(
|
|
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
|
|
"new branch should exist"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg_attr(
|
|
windows,
|
|
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
|
)]
|
|
fn rename_remote_branch_moves_the_remote_ref_atomically() {
|
|
let repo = init_temp_repo("rename_remote_branch");
|
|
let remote = init_bare_temp_repo("rename_remote_branch_remote");
|
|
commit_initial_file(&repo.path);
|
|
let commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
let remote_url = format!(
|
|
"file:///{}",
|
|
remote.path.to_string_lossy().replace('\\', "/")
|
|
);
|
|
|
|
run_git_test(&repo.path, ["remote", "add", "origin", remote_url.as_str()]);
|
|
run_git_test(
|
|
&repo.path,
|
|
["push", "-q", "origin", "HEAD:refs/heads/feature/old-name"],
|
|
);
|
|
run_git_test(&repo.path, ["fetch", "-q", "origin"]);
|
|
|
|
rename_remote_branch_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"origin".to_string(),
|
|
"feature/old-name".to_string(),
|
|
"feature/new-name".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
!ref_exists(&remote.path, "refs/heads/feature/old-name").unwrap(),
|
|
"old remote branch should be gone"
|
|
);
|
|
assert_eq!(
|
|
git_output_test(&remote.path, ["rev-parse", "refs/heads/feature/new-name"]),
|
|
commit
|
|
);
|
|
assert!(
|
|
!ref_exists(&repo.path, "refs/remotes/origin/feature/old-name").unwrap(),
|
|
"old remote-tracking branch should be gone"
|
|
);
|
|
assert!(
|
|
ref_exists(&repo.path, "refs/remotes/origin/feature/new-name").unwrap(),
|
|
"new remote-tracking branch should exist"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_branch_removes_local_branch_but_rejects_current_branch() {
|
|
let repo = init_temp_repo("delete_branch");
|
|
commit_initial_file(&repo.path);
|
|
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
run_git_test(&repo.path, ["branch", "stale"]);
|
|
|
|
let status = delete_branch_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"stale".to_string(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
|
|
assert!(
|
|
!ref_exists(&repo.path, "refs/heads/stale").unwrap(),
|
|
"deleted branch should be gone"
|
|
);
|
|
|
|
let err =
|
|
delete_branch_core(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
|
|
assert!(err.contains("current branch"));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_branch_can_force_delete_unmerged_branch() {
|
|
let repo = init_temp_repo("delete_branch_force");
|
|
commit_initial_file(&repo.path);
|
|
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/unmerged"]);
|
|
fs::write(repo.path.join("feature.txt"), "feature\n")
|
|
.expect("feature file should be written");
|
|
run_git_test(&repo.path, ["add", "feature.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]);
|
|
run_git_test(&repo.path, ["checkout", "-q", current.as_str()]);
|
|
|
|
let err = delete_branch_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"feature/unmerged".to_string(),
|
|
Some(false),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.contains("not fully merged"));
|
|
|
|
delete_branch_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"feature/unmerged".to_string(),
|
|
Some(true),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
!ref_exists(&repo.path, "refs/heads/feature/unmerged").unwrap(),
|
|
"force-deleted branch should be gone"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_file_patch_stages_and_discards_selected_changes() {
|
|
let repo = init_temp_repo("apply_file_patch");
|
|
fs::write(repo.path.join("old.txt"), "one\ntwo\nthree\n")
|
|
.expect("initial file should be written");
|
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
|
|
|
|
fs::write(repo.path.join("old.txt"), "one\nTWO\nthree\nfour\n")
|
|
.expect("changed file should be written");
|
|
|
|
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
|
|
let status = apply_file_patch(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
selected_patch.to_string(),
|
|
"stage".to_string(),
|
|
)
|
|
.expect("selected line should stage");
|
|
|
|
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
|
|
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
|
|
assert_eq!(
|
|
git_output_test(&repo.path, ["show", ":old.txt"]),
|
|
"one\nTWO\nthree"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(repo.path.join("old.txt"))
|
|
.expect("working tree should be readable")
|
|
.replace("\r\n", "\n"),
|
|
"one\nTWO\nthree\nfour\n"
|
|
);
|
|
|
|
let staged_patch = get_file_patch(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
true,
|
|
)
|
|
.expect("staged patch should load");
|
|
let status = apply_file_patch(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
staged_patch,
|
|
"unstage".to_string(),
|
|
)
|
|
.expect("selected staged lines should unstage");
|
|
|
|
assert_eq!(status.files[0].staged, None);
|
|
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
|
|
assert_eq!(
|
|
git_output_test(&repo.path, ["show", ":old.txt"]),
|
|
"one\ntwo\nthree"
|
|
);
|
|
|
|
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
|
|
apply_file_patch(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
selected_patch.to_string(),
|
|
"stage".to_string(),
|
|
)
|
|
.expect("selected line should stage again");
|
|
|
|
let unstaged_patch = get_file_patch(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
false,
|
|
)
|
|
.expect("unstaged patch should load");
|
|
assert!(unstaged_patch.contains("+four"));
|
|
|
|
let status = apply_file_patch(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
unstaged_patch,
|
|
"discard-unstaged".to_string(),
|
|
)
|
|
.expect("unstaged line should discard");
|
|
|
|
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
|
|
assert_eq!(status.files[0].unstaged, None);
|
|
assert_eq!(
|
|
fs::read_to_string(repo.path.join("old.txt"))
|
|
.expect("working tree should be readable")
|
|
.replace("\r\n", "\n"),
|
|
"one\nTWO\nthree\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_file_patch_splits_distant_changes_like_interactive_diff() {
|
|
let repo = init_temp_repo("file_patch_hunks");
|
|
let original = (1..=30)
|
|
.map(|line| format!("line {line}\n"))
|
|
.collect::<String>();
|
|
fs::write(repo.path.join("old.txt"), original).expect("initial file should be written");
|
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
|
|
|
|
let changed = (1..=30)
|
|
.map(|line| match line {
|
|
5 => "line five changed\n".to_string(),
|
|
20 => "line twenty changed\n".to_string(),
|
|
_ => format!("line {line}\n"),
|
|
})
|
|
.collect::<String>();
|
|
fs::write(repo.path.join("old.txt"), changed).expect("changed file should be written");
|
|
|
|
let patch = get_file_patch(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
false,
|
|
)
|
|
.expect("patch should load");
|
|
let hunk_count = patch.lines().filter(|line| line.starts_with("@@ ")).count();
|
|
|
|
assert_eq!(hunk_count, 2, "{patch}");
|
|
}
|
|
|
|
#[test]
|
|
fn restore_to_commit_leaves_branch_untouched_and_stages_change_as_unstaged() {
|
|
let repo = init_temp_repo("restore_to_commit");
|
|
commit_initial_file(&repo.path);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(repo.path.join("old.txt"), "second\n").expect("second file should be written");
|
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
|
let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
let status = restore_to_commit(
|
|
repo.path.to_string_lossy().to_string(),
|
|
first_commit.clone(),
|
|
)
|
|
.unwrap();
|
|
let current_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
let contents = fs::read_to_string(repo.path.join("old.txt")).unwrap();
|
|
|
|
// The branch must stay exactly where it was: no commit is rewritten or hidden.
|
|
assert_eq!(current_commit, second_commit);
|
|
assert_ne!(current_commit, first_commit);
|
|
// The old content lands in the worktree as a reviewable, unstaged change.
|
|
assert_eq!(contents.replace("\r\n", "\n"), "original\n");
|
|
assert!(!status.clean, "{:?}", status.files);
|
|
assert_eq!(
|
|
status
|
|
.files
|
|
.iter()
|
|
.find(|f| f.path == "old.txt")
|
|
.and_then(|f| f.unstaged),
|
|
Some(FileStatusKind::Modified)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_file_from_commit_restores_only_selected_file_to_worktree() {
|
|
let repo = init_temp_repo("restore_file_from_commit");
|
|
commit_initial_file(&repo.path);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(repo.path.join("old.txt"), "second\n").expect("tracked file should be changed");
|
|
fs::write(repo.path.join("other.txt"), "other\n").expect("other file should be written");
|
|
run_git_test(&repo.path, ["add", "old.txt", "other.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
|
|
|
fs::write(repo.path.join("old.txt"), "working\n").expect("working file should change");
|
|
fs::write(repo.path.join("other.txt"), "working other\n")
|
|
.expect("other working file should change");
|
|
|
|
let status = restore_file_from_commit(
|
|
repo.path.to_string_lossy().to_string(),
|
|
first_commit,
|
|
"old.txt".to_string(),
|
|
)
|
|
.unwrap();
|
|
let old_contents = fs::read_to_string(repo.path.join("old.txt")).unwrap();
|
|
let other_contents = fs::read_to_string(repo.path.join("other.txt")).unwrap();
|
|
|
|
assert_eq!(old_contents.replace("\r\n", "\n"), "original\n");
|
|
assert_eq!(other_contents.replace("\r\n", "\n"), "working other\n");
|
|
assert!(status.files.iter().any(|file| file.path == "old.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn restore_file_from_commit_can_restore_a_folder_path() {
|
|
let repo = init_temp_repo("restore_folder_from_commit");
|
|
fs::create_dir_all(repo.path.join("src")).expect("src directory should be created");
|
|
fs::create_dir_all(repo.path.join("docs")).expect("docs directory should be created");
|
|
fs::write(repo.path.join("src/a.txt"), "a1\n").expect("src a should be written");
|
|
fs::write(repo.path.join("src/b.txt"), "b1\n").expect("src b should be written");
|
|
fs::write(repo.path.join("docs/readme.txt"), "docs1\n").expect("docs should be written");
|
|
run_git_test(&repo.path, ["add", "."]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "initial tree"]);
|
|
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
|
|
|
fs::write(repo.path.join("src/a.txt"), "a2\n").expect("src a should change");
|
|
fs::write(repo.path.join("src/b.txt"), "b2\n").expect("src b should change");
|
|
fs::write(repo.path.join("docs/readme.txt"), "docs2\n").expect("docs should change");
|
|
|
|
restore_file_from_commit(
|
|
repo.path.to_string_lossy().to_string(),
|
|
first_commit,
|
|
"src".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
let src_a = fs::read_to_string(repo.path.join("src/a.txt")).unwrap();
|
|
let src_b = fs::read_to_string(repo.path.join("src/b.txt")).unwrap();
|
|
let docs = fs::read_to_string(repo.path.join("docs/readme.txt")).unwrap();
|
|
|
|
assert_eq!(src_a.replace("\r\n", "\n"), "a1\n");
|
|
assert_eq!(src_b.replace("\r\n", "\n"), "b1\n");
|
|
assert_eq!(docs.replace("\r\n", "\n"), "docs2\n");
|
|
}
|
|
|
|
#[test]
|
|
fn repository_files_include_tracked_deleted_and_untracked_entries() {
|
|
let repo = init_temp_repo("repository_files");
|
|
commit_initial_file(&repo.path);
|
|
fs::remove_file(repo.path.join("old.txt")).expect("tracked file should be deleted");
|
|
fs::write(repo.path.join("new.txt"), "new\n").expect("untracked file should be written");
|
|
|
|
let files = repository_files(&repo.path).unwrap();
|
|
|
|
assert!(files.iter().any(|file| {
|
|
file.path == "old.txt" && file.tracked && file.status == Some(FileStatusKind::Deleted)
|
|
}));
|
|
assert!(files.iter().any(|file| {
|
|
file.path == "new.txt"
|
|
&& !file.tracked
|
|
&& file.status == Some(FileStatusKind::Untracked)
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn list_file_history_returns_commits_for_selected_file() {
|
|
let repo = init_temp_repo("file_history");
|
|
commit_initial_file(&repo.path);
|
|
fs::write(repo.path.join("old.txt"), "second\n").expect("tracked file should change");
|
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "touch selected"]);
|
|
fs::write(repo.path.join("other.txt"), "other\n").expect("other file should be written");
|
|
run_git_test(&repo.path, ["add", "other.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "touch other"]);
|
|
|
|
let commits =
|
|
list_file_history_core(&repo.path, "old.txt".to_string(), Some(10), None).unwrap();
|
|
|
|
assert_eq!(commits.len(), 2);
|
|
assert_eq!(commits[0].summary, "touch selected");
|
|
assert_eq!(commits[1].summary, "init");
|
|
}
|
|
|
|
#[test]
|
|
fn list_file_history_returns_commits_for_selected_folder() {
|
|
let repo = init_temp_repo("folder_history");
|
|
fs::create_dir_all(repo.path.join("src")).expect("src directory should be created");
|
|
fs::create_dir_all(repo.path.join("docs")).expect("docs directory should be created");
|
|
fs::write(repo.path.join("src/a.txt"), "a1\n").expect("src file should be written");
|
|
run_git_test(&repo.path, ["add", "."]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "src initial"]);
|
|
fs::write(repo.path.join("docs/readme.txt"), "docs\n")
|
|
.expect("docs file should be written");
|
|
run_git_test(&repo.path, ["add", "."]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "docs only"]);
|
|
fs::write(repo.path.join("src/a.txt"), "a2\n").expect("src file should change");
|
|
run_git_test(&repo.path, ["add", "."]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "src update"]);
|
|
|
|
let commits =
|
|
list_file_history_core(&repo.path, "src".to_string(), Some(10), None).unwrap();
|
|
|
|
assert_eq!(commits.len(), 2);
|
|
assert_eq!(commits[0].summary, "src update");
|
|
assert_eq!(commits[1].summary, "src initial");
|
|
}
|
|
|
|
#[test]
|
|
fn list_file_history_can_be_cancelled() {
|
|
let repo = init_temp_repo("file_history_cancelled");
|
|
commit_initial_file(&repo.path);
|
|
|
|
let state = SearchCancellationState::default();
|
|
state
|
|
.cancel("file-history-test")
|
|
.expect("cancel flag should be set");
|
|
|
|
let result = list_file_history_core(
|
|
&repo.path,
|
|
"old.txt".to_string(),
|
|
Some(10),
|
|
Some(&SearchCancellation {
|
|
state,
|
|
search_id: "file-history-test".to_string(),
|
|
}),
|
|
);
|
|
|
|
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
|
}
|
|
|
|
#[test]
|
|
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
|
|
let repo = init_temp_repo("file_blame");
|
|
commit_initial_file(&repo.path);
|
|
fs::write(repo.path.join("old.txt"), "original\ntwo\n")
|
|
.expect("tracked file should change");
|
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
|
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
|
|
|
|
let result = get_file_blame(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(result.path, "old.txt");
|
|
assert_eq!(result.lines.len(), 2);
|
|
assert_eq!(result.lines[0].content, "original");
|
|
assert_eq!(result.lines[0].summary, "init");
|
|
assert_eq!(result.lines[1].content, "two");
|
|
assert_eq!(result.lines[1].summary, "add second line");
|
|
assert!(!result.lines[1].author_name.is_empty());
|
|
assert!(!result.lines[1].is_uncommitted);
|
|
}
|
|
|
|
#[test]
|
|
fn get_file_blame_marks_uncommitted_working_tree_changes() {
|
|
let repo = init_temp_repo("file_blame_uncommitted");
|
|
commit_initial_file(&repo.path);
|
|
fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change");
|
|
|
|
let result = get_file_blame(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(result.lines.len(), 1);
|
|
assert!(result.lines[0].is_uncommitted);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_worktree_porcelain_preserves_flags_and_reasons() {
|
|
let current = Path::new("/repos/main");
|
|
let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0";
|
|
|
|
let rows = parse_worktree_porcelain(output, current);
|
|
|
|
assert_eq!(rows.len(), 2);
|
|
assert!(rows[0].is_main);
|
|
assert_eq!(rows[0].branch.as_deref(), Some("main"));
|
|
assert_eq!(rows[0].short_head.as_deref(), Some("12345678"));
|
|
assert!(rows[1].detached);
|
|
assert!(rows[1].locked);
|
|
assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive"));
|
|
assert!(rows[1].prunable);
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_add_list_and_remove_round_trip() {
|
|
let repo = init_temp_repo("worktree_round_trip");
|
|
let destination = temp_dir("worktree_round_trip_destination");
|
|
commit_initial_file(&repo.path);
|
|
run_git_test(&repo.path, ["branch", "feature"]);
|
|
|
|
let rows = add_worktree_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
destination.path.to_string_lossy().to_string(),
|
|
Some("feature".to_string()),
|
|
None,
|
|
None,
|
|
Some(false),
|
|
Some(false),
|
|
)
|
|
.expect("worktree should be created");
|
|
|
|
let linked = rows
|
|
.iter()
|
|
.find(|row| row.branch.as_deref() == Some("feature"))
|
|
.expect("linked worktree should be listed");
|
|
assert!(!linked.is_main);
|
|
assert!(linked.clean);
|
|
|
|
let rows = lock_worktree_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
destination.path.to_string_lossy().to_string(),
|
|
Some("test lock".to_string()),
|
|
)
|
|
.expect("worktree should lock");
|
|
let linked = rows
|
|
.iter()
|
|
.find(|row| row.branch.as_deref() == Some("feature"))
|
|
.expect("linked worktree should remain listed");
|
|
assert!(linked.locked);
|
|
assert_eq!(linked.lock_reason.as_deref(), Some("test lock"));
|
|
|
|
unlock_worktree_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
destination.path.to_string_lossy().to_string(),
|
|
)
|
|
.expect("worktree should unlock");
|
|
|
|
let rows = remove_worktree_core(
|
|
repo.path.to_string_lossy().to_string(),
|
|
destination.path.to_string_lossy().to_string(),
|
|
Some(false),
|
|
)
|
|
.expect("worktree should be removed");
|
|
assert_eq!(rows.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
|
|
let raw = r#"```json
|
|
{"summary":"One issue found","risk":"HIGH","findings":[{"severity":"warn","title":"Unchecked result","description":"The new call ignores an error.","file":"src/main.rs","line":42,"suggestion":"Propagate the error."}]}
|
|
```"#;
|
|
|
|
let review = parse_ai_review(raw).expect("review JSON should parse");
|
|
|
|
assert_eq!(review.risk, AiReviewRisk::High);
|
|
assert_eq!(review.findings.len(), 1);
|
|
assert_eq!(review.findings[0].severity, AiReviewSeverity::Warning);
|
|
assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs"));
|
|
assert_eq!(review.findings[0].line, Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_ai_commit_plan_requires_each_staged_file_exactly_once() {
|
|
let files = vec!["src/app.ts".to_string(), "tests/app.test.ts".to_string()];
|
|
let raw = r#"{"summary":"Separate behavior and coverage","groups":[{"message":"feat(app): add behavior","reason":"Production code","files":["src/app.ts"]},{"message":"test(app): cover behavior","reason":"Tests","files":["tests/app.test.ts"]}]}"#;
|
|
let plan = parse_ai_commit_plan(raw, &files).expect("complete plan should parse");
|
|
assert_eq!(plan.groups.len(), 2);
|
|
|
|
let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#;
|
|
assert!(parse_ai_commit_plan(duplicate, &files).is_err());
|
|
}
|
|
}
|