This update introduces pagination and skipping functionality for the commit listing feature, allowing users to load commits in pages and navigate through them more efficiently. The UI has been adjusted to support loading more commits dynamically, improving the overall user experience when dealing with large repositories. - Added pagination support for commit history - Introduced a loading mechanism for fetching more commits - Updated UI components to reflect changes in commit loading behavior
7291 lines
237 KiB
Rust
7291 lines
237 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,
|
|
}
|
|
|
|
#[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]
|
|
pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
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]
|
|
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
|
let repo = resolve_repo(&path)?;
|
|
open_path_in_file_manager(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
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>,
|
|
}
|
|
|
|
#[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> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<RepositoryBundle, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let status = status_for_repo(&repo)?;
|
|
let branches = branches_for_repo(&repo)?;
|
|
let tags = tags_for_repo(&repo)?;
|
|
let stashes = stashes_for_repo(&repo)?;
|
|
let commits = commits_for_repo(&repo, commit_limit)?;
|
|
let files = repository_files_with_status(&repo, &status)?;
|
|
Ok(RepositoryBundle {
|
|
status,
|
|
branches,
|
|
tags,
|
|
stashes,
|
|
commits,
|
|
files,
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|err| format!("Could not load repository: {err}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_status(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
branches_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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 fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
stashes_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
tags_for_repo(&repo)
|
|
}
|
|
|
|
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)",
|
|
"refs/heads",
|
|
"refs/remotes",
|
|
],
|
|
)?;
|
|
let text = String::from_utf8_lossy(&output);
|
|
let mut branches = Vec::new();
|
|
|
|
for line in text.lines() {
|
|
let Some((ref_name, head_marker)) = line.split_once('\t') else {
|
|
continue;
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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 fn stash_push(
|
|
path: String,
|
|
message: Option<String>,
|
|
include_untracked: bool,
|
|
) -> Result<GitStatus, String> {
|
|
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)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
|
|
run_stash_update(path, "apply", selector)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
|
|
run_stash_update(path, "pop", selector)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
|
|
run_stash_update(path, "drop", selector)
|
|
}
|
|
|
|
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 fn checkout_branch(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 fn create_branch(
|
|
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 fn rename_branch(
|
|
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 fn delete_branch(
|
|
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 fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub 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> {
|
|
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 fn remove_worktree(
|
|
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 fn move_worktree(
|
|
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 fn lock_worktree(
|
|
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 fn unlock_worktree(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 fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
run_git(&repo, ["worktree", "prune"])?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn repair_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
|
|
worktrees_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_tag(
|
|
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 fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
|
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)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn push_tag(
|
|
path: String,
|
|
name: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
) -> Result<(), String> {
|
|
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(())
|
|
}
|
|
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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,
|
|
#[serde(default, rename = "expiresAt", skip_serializing_if = "Option::is_none")]
|
|
pub expires_at: Option<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]
|
|
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]
|
|
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]
|
|
pub fn cred_save(
|
|
key: String,
|
|
username: String,
|
|
password: String,
|
|
expires_at: Option<String>,
|
|
) -> Result<(), String> {
|
|
let entry = cred_entry(&key)?;
|
|
let expires_at = expires_at.filter(|value| !value.trim().is_empty());
|
|
let cred = StoredCredential {
|
|
username,
|
|
password,
|
|
expires_at,
|
|
};
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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 fn list_commits(
|
|
path: String,
|
|
limit: Option<u32>,
|
|
skip: Option<u32>,
|
|
) -> Result<Vec<GitCommit>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
commit_page_for_repo(&repo, limit, skip)
|
|
}
|
|
|
|
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 fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
repository_files(&repo)
|
|
}
|
|
|
|
#[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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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())?;
|
|
let status = status_for_repo(&repo)?;
|
|
let branches = branches_for_repo(&repo)?;
|
|
let tags = tags_for_repo(&repo)?;
|
|
let stashes = stashes_for_repo(&repo)?;
|
|
let commits = commits_for_repo(&repo, commit_limit)?;
|
|
let files = repository_files_with_status(&repo, &status)?;
|
|
|
|
Ok(RepositoryBundle {
|
|
status,
|
|
branches,
|
|
tags,
|
|
stashes,
|
|
commits,
|
|
files,
|
|
})
|
|
}
|
|
|
|
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 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_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(
|
|
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(
|
|
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(
|
|
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]
|
|
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(
|
|
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(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(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"feature/unmerged".to_string(),
|
|
Some(false),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.contains("not fully merged"));
|
|
|
|
delete_branch(
|
|
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(
|
|
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(
|
|
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(
|
|
repo.path.to_string_lossy().to_string(),
|
|
destination.path.to_string_lossy().to_string(),
|
|
)
|
|
.expect("worktree should unlock");
|
|
|
|
let rows = remove_worktree(
|
|
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());
|
|
}
|
|
}
|