1460 lines
45 KiB
Rust
1460 lines
45 KiB
Rust
use serde::Serialize;
|
|
use std::{
|
|
collections::BTreeMap,
|
|
ffi::{OsStr, OsString},
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
};
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[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 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 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 GitRepositoryFile {
|
|
pub path: String,
|
|
pub tracked: bool,
|
|
pub status: Option<FileStatusKind>,
|
|
}
|
|
|
|
#[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),
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[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)?;
|
|
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)
|
|
}
|
|
|
|
#[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 darf nicht leer sein.".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 stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
validate_files(&files)?;
|
|
|
|
if !files.is_empty() {
|
|
run_git_with_paths(&repo, &["add"], &files)?;
|
|
}
|
|
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn unstage_files(path: String, files: Vec<String>) -> 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)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> 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)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if message.trim().is_empty() {
|
|
return Err("Commit-Message darf nicht leer sein.".to_string());
|
|
}
|
|
|
|
run_git(&repo, ["commit", "-m", message.as_str()])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn pull(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
run_git(&repo, ["pull", "--ff-only"])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn push(path: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
run_git(&repo, ["push"])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
let branch = branch.trim();
|
|
if branch.is_empty() {
|
|
return Err("Branch-Name darf nicht leer sein.".to_string());
|
|
}
|
|
|
|
run_git(&repo, ["merge", "--no-edit", branch])?;
|
|
status_for_repo(&repo)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
if verify_commit(&repo, "HEAD").is_err() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
|
|
let output = run_git(
|
|
&repo,
|
|
[
|
|
"log",
|
|
"--decorate=short",
|
|
"--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%s%x1e",
|
|
"-n",
|
|
limit.as_str(),
|
|
],
|
|
)?;
|
|
|
|
parse_commit_log(&repo, &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 fn list_file_history(
|
|
path: String,
|
|
file: String,
|
|
limit: Option<u32>,
|
|
) -> Result<Vec<GitCommit>, String> {
|
|
let repo = resolve_repo(&path)?;
|
|
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%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(&repo, args)?;
|
|
|
|
parse_commit_log(&repo, &output)
|
|
}
|
|
|
|
#[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)?;
|
|
|
|
run_git(&repo, ["reset", "--hard", commit_hash.as_str()])?;
|
|
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)
|
|
}
|
|
|
|
fn resolve_repo(path: &str) -> Result<PathBuf, String> {
|
|
if path.trim().is_empty() {
|
|
return Err("Repository-Pfad darf nicht leer sein.".to_string());
|
|
}
|
|
|
|
let input = PathBuf::from(path);
|
|
let output = run_git_at(
|
|
&input,
|
|
["rev-parse", "--show-toplevel"],
|
|
"Kein Git-Repository oder nicht erreichbar",
|
|
)?;
|
|
let top_level = String::from_utf8_lossy(&output).trim().to_string();
|
|
|
|
if top_level.is_empty() {
|
|
return Err("Git konnte keinen Repository-Wurzelpfad ermitteln.".to_string());
|
|
}
|
|
|
|
Ok(PathBuf::from(top_level))
|
|
}
|
|
|
|
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
|
|
let commit = commit.trim();
|
|
if commit.is_empty() {
|
|
return Err("Commit darf nicht leer sein.".to_string());
|
|
}
|
|
|
|
let rev = format!("{commit}^{{commit}}");
|
|
let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()])
|
|
.map_err(|err| format!("Commit konnte nicht gefunden werden: {err}"))?;
|
|
let hash = String::from_utf8_lossy(&output).trim().to_string();
|
|
|
|
if hash.is_empty() {
|
|
return Err("Commit konnte nicht gefunden werden.".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, files) = parse_status_output(&output)?;
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
|
let status = status_for_repo(repo)?;
|
|
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 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 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(7, FIELD_SEPARATOR).collect();
|
|
if fields.len() != 7 {
|
|
return Err(format!("Unerwarteter Git-Log-Eintrag: {record}"));
|
|
}
|
|
|
|
let refs = fields[5]
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.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,
|
|
summary: fields[6].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(7, FIELD_SEPARATOR).collect();
|
|
if fields.len() != 7 {
|
|
return Err(format!("Unerwarteter Git-Log-Eintrag: {record}"));
|
|
}
|
|
|
|
let refs = fields[5]
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.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,
|
|
summary: fields[6].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-Eintrag ohne Pfad: {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 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 ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
|
let output = Command::new("git")
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["show-ref", "--verify", "--quiet", ref_name])
|
|
.output()
|
|
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {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() {
|
|
"unbekannter Fehler".to_string()
|
|
} else {
|
|
stderr.trim().to_string()
|
|
};
|
|
Err(format!("Git-Ref konnte nicht geprueft werden: {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);
|
|
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", "--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("Dateiliste enthaelt einen leeren Pfad.".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_git_with_paths(
|
|
repo: &Path,
|
|
base_args: &[&str],
|
|
files: &[String],
|
|
) -> 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(repo, args)
|
|
}
|
|
|
|
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-Befehl fehlgeschlagen")
|
|
}
|
|
|
|
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 = Command::new("git")
|
|
.arg("-C")
|
|
.arg(path)
|
|
.args(args)
|
|
.output()
|
|
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {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 {
|
|
"unbekannter Fehler"
|
|
};
|
|
|
|
Err(format!("{context}: {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 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");
|
|
run_git_test(&path, ["init", "-q"]);
|
|
run_git_test(&path, ["config", "user.email", "test@example.com"]);
|
|
run_git_test(&path, ["config", "user.name", "Tester"]);
|
|
|
|
TempRepo { path }
|
|
}
|
|
|
|
fn run_git_test<I, S>(repo: &Path, args: I)
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let output = Command::new("git")
|
|
.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 = Command::new("git")
|
|
.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 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_commit_history_records() {
|
|
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\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()],
|
|
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 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 restore_to_commit_resets_branch_to_selected_commit() {
|
|
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 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();
|
|
|
|
assert_eq!(current_commit, first_commit);
|
|
assert_eq!(contents.replace("\r\n", "\n"), "original\n");
|
|
assert!(status.clean, "{:?}", status.files);
|
|
}
|
|
|
|
#[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(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"old.txt".to_string(),
|
|
Some(10),
|
|
)
|
|
.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(
|
|
repo.path.to_string_lossy().to_string(),
|
|
"src".to_string(),
|
|
Some(10),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(commits.len(), 2);
|
|
assert_eq!(commits[0].summary, "src update");
|
|
assert_eq!(commits[1].summary, "src initial");
|
|
}
|
|
}
|