Merge remote-tracking branch 'origin/main'
This commit is contained in:
+580
-7
@@ -1,7 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
env,
|
||||
ffi::{OsStr, OsString},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output, Stdio},
|
||||
sync::{
|
||||
@@ -170,6 +172,112 @@ pub struct GitSearchHit {
|
||||
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>,
|
||||
@@ -838,7 +946,11 @@ pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
|
||||
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)?;
|
||||
@@ -1481,6 +1593,86 @@ pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, St
|
||||
.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)?;
|
||||
@@ -1488,15 +1680,27 @@ pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
|
||||
return Err("No rebase is currently in progress.".to_string());
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["rebase", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
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}"))?;
|
||||
|
||||
rebase_status_or_error(&repo, output, "Rebase continue failed", false)
|
||||
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]
|
||||
@@ -1507,9 +1711,249 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, 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,
|
||||
@@ -2521,6 +2965,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
)?;
|
||||
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(),
|
||||
@@ -4582,6 +5027,134 @@ mod tests {
|
||||
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";
|
||||
|
||||
+24
-9
@@ -12,14 +12,16 @@ use git::{
|
||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
||||
stash_push, undo_last_commit, unstage_files,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_repository_files,
|
||||
list_stashes, list_tags, merge_branch, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, pull, push, push_tag, read_conflict,
|
||||
rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict,
|
||||
resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry,
|
||||
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files,
|
||||
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
|
||||
unstage_files,
|
||||
};
|
||||
use tauri::{Manager, AppHandle};
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
@@ -42,10 +44,19 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let builder = tauri::Builder::default()
|
||||
if let Some(result) = run_sequence_editor_if_requested() {
|
||||
if let Err(error) = result {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
#[cfg(desktop)]
|
||||
let _ = app.get_webview_window("main")
|
||||
let _ = app
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
}))
|
||||
@@ -114,6 +125,10 @@ async fn main() {
|
||||
rebase_branch,
|
||||
rebase_continue,
|
||||
rebase_abort,
|
||||
list_interactive_rebase_commits,
|
||||
start_interactive_rebase,
|
||||
list_reflog,
|
||||
restore_reflog_entry,
|
||||
list_repository_files,
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.7.18",
|
||||
"version": "2026.7.19",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user