feat(git): expand core git repository management features
This update significantly expands the available Git functionality by adding robust support for initializing repositories, managing remotes, and improving complex workflow operations like merging and reverting commits. New API endpoints are exposed across the backend and frontend to handle remote setup, branch tracking, and conflict resolution workflows. - Added full remote management capabilities (add, update, remove). - Implemented advanced merge strategies and commit reversion logic. - Introduced a dedicated UI component for synchronization settings.
This commit is contained in:
+321
-15
@@ -51,6 +51,14 @@ pub struct GitStatus {
|
||||
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)]
|
||||
@@ -357,6 +365,34 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
||||
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)?;
|
||||
@@ -455,6 +491,102 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
||||
branches_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
|
||||
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> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_remote_name(&repo, &name, true)?;
|
||||
run_git(&repo, ["remote", "remove", name.as_str()])?;
|
||||
list_remotes(path)
|
||||
}
|
||||
|
||||
#[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> {
|
||||
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])?;
|
||||
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -1405,18 +1537,43 @@ 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 pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
|
||||
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, u, p)?
|
||||
run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
|
||||
}
|
||||
_ => git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(pull_args)
|
||||
.args(&pull_args)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||
};
|
||||
@@ -1445,18 +1602,30 @@ 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 fetch_args = ["fetch"];
|
||||
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, u, p)?
|
||||
run_git_authenticated_output(&repo, fetch_args.clone(), u, p)?
|
||||
}
|
||||
_ => git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(fetch_args)
|
||||
.args(&fetch_args)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||
};
|
||||
@@ -1480,10 +1649,15 @@ 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 push_args = push_args_for_repo(&repo)?;
|
||||
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)?;
|
||||
@@ -1553,6 +1727,43 @@ fn remote_url_for(repo: &Path, remote: &str) -> Option<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()
|
||||
@@ -1605,7 +1816,25 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
|
||||
.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")]);
|
||||
}
|
||||
@@ -1665,7 +1894,11 @@ pub fn cred_delete(key: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
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();
|
||||
@@ -1673,10 +1906,19 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
|
||||
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(["merge", "--no-edit", branch])
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
@@ -1708,6 +1950,52 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
|
||||
.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> {
|
||||
@@ -3114,6 +3402,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
files,
|
||||
rebase_in_progress: rebase_in_progress(repo),
|
||||
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
||||
merge_in_progress: merge_in_progress(repo),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3125,6 +3414,10 @@ 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;
|
||||
@@ -5626,9 +5919,16 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
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());
|
||||
@@ -5699,9 +5999,15 @@ mod tests {
|
||||
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
||||
);
|
||||
|
||||
let status = push(repo.path.to_string_lossy().to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = push(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
status.upstream.as_deref(),
|
||||
|
||||
+22
-10
@@ -5,21 +5,23 @@ mod git;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use git::{
|
||||
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
||||
SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search,
|
||||
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
|
||||
compare_file_to_head, 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_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,
|
||||
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||
list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote,
|
||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
|
||||
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files,
|
||||
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
|
||||
unstage_files,
|
||||
unstage_files, update_remote,
|
||||
};
|
||||
use tauri::Manager;
|
||||
|
||||
@@ -82,11 +84,18 @@ async fn main() {
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
init_repository,
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
get_status,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
add_remote,
|
||||
update_remote,
|
||||
remove_remote,
|
||||
set_branch_upstream,
|
||||
delete_remote_branch,
|
||||
list_stashes,
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
@@ -124,6 +133,9 @@ async fn main() {
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
merge_continue,
|
||||
merge_abort,
|
||||
revert_commit,
|
||||
rebase_branch,
|
||||
rebase_continue,
|
||||
rebase_abort,
|
||||
|
||||
Reference in New Issue
Block a user