use super::*;
#[derive(Clone)]
pub(super) struct RepoSender {
pub(super) tx: std::sync::mpsc::Sender<String>,
pub(super) path: std::path::PathBuf,
}
impl RepoSender {
pub(super) fn send(&self, msg: String) -> Result<(), std::sync::mpsc::SendError<String>> {
self.tx.send(format!("{}|||{}", self.path.to_string_lossy(), msg))
}
}
impl App {
pub fn pull_selected_branch(&mut self) {
if self.fetching {
return;
}
if let Some(repo::ItemDetail::Repo { resolved, info }) = &self.current_detail {
if let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
{
if !branch_info.is_head {
self.status_message = Some(format!(
"Can only pull into the currently checked-out branch. Checkout '{}' first.",
branch_info.name
));
return;
}
let r_name = resolved.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url = repo::get_default_remote_url(resolved)
.unwrap_or_else(|| "(no url)".to_string());
crate::debug_log::info(format!(
"Network Action: Pulling branch '{}' for repository '{}' [url: '{}'] (user triggered)",
branch_info.name, r_name, remote_url
));
self.fetching = true;
self.status_message = Some("Pulling...".to_string());
let repo_path = resolved.clone();
let branch_name = branch_info.name.clone();
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<String, Box<dyn std::error::Error>> {
if !repo::has_upstream_remote(&repo_path, &branch_name) {
return Ok("No upstream tracking branch configured for this branch"
.to_string());
}
let _safe_branch = safe_ref(&branch_name)?;
let mut cmd = git_command();
cmd.arg("pull").current_dir(&repo_path);
let output = crate::git_cmd::run_git_with_timeout(cmd, timeout)?;
if output.status.success() {
Ok(format!("Pulled successfully for '{}'", branch_name))
} else {
let err_msg =
String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(format!("git pull failed: {}", err_msg).into())
}
})();
let msg = match res {
Ok(success) => success,
Err(e) => format!("Pull failed: {}", e),
};
let _ = tx.send(msg);
});
}
}
}
pub fn lfs_pull(&mut self) {
if self.fetching {
return;
}
if let Some(repo::ItemDetail::Repo { resolved, info }) = &self.current_detail {
if !info.lfs_installed {
self.status_message = Some("Git LFS is not installed on this system".to_string());
return;
}
let r_name = resolved.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url =
repo::get_default_remote_url(resolved).unwrap_or_else(|| "(no url)".to_string());
crate::debug_log::info(format!(
"Network Action: git lfs pull for repository '{}' [url: '{}'] (user triggered)",
r_name, remote_url
));
self.fetching = true;
self.status_message = Some("Git LFS Pulling...".to_string());
let repo_path = resolved.clone();
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<String, Box<dyn std::error::Error>> {
let mut cmd = git_command();
cmd.arg("lfs").arg("pull").current_dir(&repo_path);
let output = crate::git_cmd::run_git_with_timeout(cmd, timeout)?;
if output.status.success() {
Ok("Git LFS files pulled successfully".to_string())
} else {
let err_msg =
crate::fetch_error::sanitize(&String::from_utf8_lossy(&output.stderr));
Err(format!("git lfs pull failed: {}", err_msg).into())
}
})();
let msg = match res {
Ok(success) => success,
Err(e) => format!("LFS pull failed: {}", e),
};
let _ = tx.send(msg);
});
}
}
pub fn request_branch_push(&mut self) {
if self.fetching {
return;
}
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
{
let branch_name = branch_info.name.clone();
if branch_name == "HEAD" {
self.status_message = Some("Cannot push detached HEAD.".to_string());
return;
}
self.branch_push_tags = false;
if info.remotes.len() > 1 {
self.branch_action_target = Some((branch_name, false));
self.remote_picker_action = Some(RemotePickerAction::PushBranch);
self.remote_picker_selection = 0;
self.mode = Mode::RemotePicker;
} else {
self.branch_action_target = Some((branch_name, false));
self.mode = Mode::BranchPushConfirm;
}
}
}
}
pub fn confirm_branch_push(&mut self) {
if let Some((branch_name, _)) = &self.branch_action_target {
let branch_name = branch_name.clone();
self.execute_branch_push(&branch_name);
}
self.branch_action_target = None;
self.mode = Mode::Detail;
}
pub fn cancel_branch_push(&mut self) {
self.branch_action_target = None;
self.branch_push_tags = false;
self.mode = Mode::Detail;
}
pub fn execute_branch_push(&mut self, branch_name: &str) {
let push_tags = self.branch_push_tags;
self.branch_push_tags = false;
if self.fetching {
return;
}
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
let repo_path = resolved.clone();
let branch_name = branch_name.to_string();
let (remote_name, set_upstream) =
match repo::get_branch_push_target(&repo_path, &branch_name) {
Some((name, set_up)) => (Some(name), set_up),
None => (None, false),
};
let remote_name = match remote_name {
Some(name) => name,
None => {
self.status_message =
Some("No remotes configured for this repository".to_string());
return;
}
};
let r_name = repo_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url = repo::get_remote_url(&repo_path, &remote_name)
.unwrap_or_else(|| "(no url)".to_string());
let tags_desc = if push_tags { " (with tags)" } else { "" };
crate::debug_log::info(format!(
"Network Action: Pushing branch '{}'{} for repository '{}' [url: '{}'] (user triggered)",
branch_name, tags_desc, r_name, remote_url
));
self.fetching = true;
self.status_message =
Some(format!("Pushing '{}'{} to '{}'...", branch_name, tags_desc, remote_name));
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<String, Box<dyn std::error::Error>> {
let safe_remote = safe_ref(&remote_name)?;
let safe_branch = safe_ref(&branch_name)?;
let mut cmd = git_command();
cmd.arg("push");
if set_upstream {
cmd.arg("-u");
}
if push_tags {
cmd.arg("--tags");
}
cmd.arg(safe_remote).arg(safe_branch).current_dir(&repo_path);
let output = crate::git_cmd::run_git_with_timeout(cmd, timeout)?;
if output.status.success() {
let tag_suffix = if push_tags { " (with tags)" } else { "" };
Ok(format!(
"Pushed '{}'{} to '{}' successfully",
branch_name, tag_suffix, remote_name
))
} else {
let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(format!("git push failed: {}", err_msg).into())
}
})();
let msg = match res {
Ok(success) => success,
Err(e) => format!("Push failed: {}", e),
};
let _ = tx.send(msg);
});
}
}
pub fn request_branch_checkout(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
match self.detail_focus {
DetailSection::LocalBranches => {
if let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
{
if !branch_info.is_head {
self.branch_action_target = Some((branch_info.name.clone(), false));
self.mode = Mode::BranchCheckoutConfirm;
}
}
}
DetailSection::RemoteBranches => {
if let Some(branch_info) =
info.remote_branches.get(self.branch_list.remote_branch_selection)
{
self.branch_action_target = Some((branch_info.name.clone(), true));
self.mode = Mode::BranchCheckoutConfirm;
}
}
_ => {}
}
}
}
pub fn confirm_branch_checkout(&mut self) {
if let Some((branch_name, is_remote)) = self.branch_action_target.take() {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
let res = if is_remote {
repo::checkout_remote_branch(resolved, &branch_name)
} else {
repo::checkout_local_branch(resolved, &branch_name)
.map(|_| format!("Switched to branch '{}'", branch_name))
};
match res {
Ok(msg) => {
self.status_message = Some(msg);
self.branch_list.local_branch_selection = 0;
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Checkout failed: {}", e));
}
}
}
}
self.mode = Mode::Detail;
}
pub fn cancel_branch_checkout(&mut self) {
self.branch_action_target = None;
self.mode = Mode::Detail;
}
pub fn start_tag_create(&mut self) {
if self.detail_tab != 0 {
return;
}
if self.detail_focus != DetailSection::Commits {
return;
}
if self.is_uncommitted_selected() {
self.status_message = Some("Cannot tag uncommitted changes".to_string());
return;
}
if let Some(commit) = self.get_selected_commit() {
self.tag_action_target_oid = Some(commit.oid.clone());
self.input_buffer.clear();
self.tag_create_message.clear();
self.tag_create_focus_message = false;
self.mode = Mode::TagCreateInput;
}
}
pub fn commit_tag_create(&mut self) {
let tag_name = self.input_buffer.trim().to_string();
if tag_name.is_empty() {
self.status_message = Some("Tag name cannot be empty".to_string());
self.input_buffer.clear();
self.tag_create_message.clear();
self.mode = Mode::Detail;
return;
}
let message = if self.tag_create_message.trim().is_empty() {
None
} else {
Some(self.tag_create_message.trim().to_string())
};
if let Some(oid) = self.tag_action_target_oid.take() {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
if repo::tag_exists(resolved, &tag_name) {
self.tag_overwrite_target = Some((tag_name, oid, message));
self.mode = Mode::TagOverwriteConfirm;
return;
}
match repo::create_tag(resolved, &tag_name, &oid, message.as_deref(), false) {
Ok(()) => {
let msg_suffix = if message.is_some() { " (annotated)" } else { "" };
self.status_message =
Some(format!("Created tag '{}'{}", tag_name, msg_suffix));
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to create tag: {}", e));
}
}
}
}
self.input_buffer.clear();
self.tag_create_message.clear();
self.mode = Mode::Detail;
}
pub fn confirm_tag_overwrite(&mut self) {
if let Some((tag_name, oid, message)) = self.tag_overwrite_target.take() {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
match repo::create_tag(resolved, &tag_name, &oid, message.as_deref(), true) {
Ok(()) => {
let short_oid = if oid.len() >= 7 { &oid[..7] } else { &oid };
self.status_message =
Some(format!("Updated tag '{}' to commit {}", tag_name, short_oid));
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to force update tag: {}", e));
}
}
}
}
self.input_buffer.clear();
self.tag_create_message.clear();
self.mode = Mode::Detail;
}
pub fn cancel_tag_overwrite(&mut self) {
self.tag_overwrite_target = None;
self.input_buffer.clear();
self.tag_create_message.clear();
self.mode = Mode::Detail;
}
pub fn start_stash_create(&mut self) {
self.input_buffer.clear();
self.mode = Mode::StashCreateInput;
}
pub fn commit_stash_create(&mut self) {
let stash_name = self.input_buffer.trim().to_string();
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
match repo::save_stash(
resolved,
&stash_name,
self.stash_untracked,
self.stash_keep_index,
) {
Ok(()) => {
let msg = if stash_name.is_empty() {
"Created stash".to_string()
} else {
format!("Created stash '{}'", stash_name)
};
self.status_message = Some(msg);
self.stash_list.stash_selection = 0;
self.stash_list.stash_file_selection = 0;
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to stash changes: {}", e));
}
}
}
self.input_buffer.clear();
self.mode = Mode::Detail;
}
pub fn start_remote_add(&mut self) {
self.mode = Mode::RemoteAddNameInput;
self.input_buffer.clear();
self.remote_add_name.clear();
self.remote_add_url.clear();
}
pub fn commit_remote_add_name(&mut self) {
let trimmed = self.input_buffer.trim().to_string();
self.input_buffer.clear();
if trimmed.is_empty() {
self.mode = Mode::Detail;
return;
}
self.remote_add_name = trimmed;
self.mode = Mode::RemoteAddUrlInput;
}
pub fn commit_remote_add_url(&mut self) {
let trimmed = self.input_buffer.trim().to_string();
self.input_buffer.clear();
self.mode = Mode::Detail;
if trimmed.is_empty() {
return;
}
self.remote_add_url = trimmed;
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
match repo::remote_add(resolved, &self.remote_add_name, &self.remote_add_url) {
Ok(_) => {
self.status_message =
Some(format!("Remote '{}' added successfully", self.remote_add_name));
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to add remote: {}", e));
}
}
}
}
pub fn request_remote_delete(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(remote_info) = info.remotes.get(self.branch_list.remote_selection) {
self.remote_action_target = Some(remote_info.name.clone());
self.mode = Mode::RemoteDeleteConfirm;
}
}
}
pub fn confirm_remote_delete(&mut self) {
if let Some(remote_name) = self.remote_action_target.take() {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
match repo::remote_delete(resolved, &remote_name) {
Ok(_) => {
self.status_message = Some(format!("Remote '{}' removed", remote_name));
self.branch_list.remote_selection = 0;
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to remove remote: {}", e));
}
}
}
}
self.mode = Mode::Detail;
}
pub fn request_tag_delete(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(tag_info) = info.local_tags.get(self.tag_list.local_tag_selection) {
let is_on_remote = !info.remotes.is_empty();
self.tag_delete_target = Some((tag_info.name.clone(), is_on_remote));
self.mode = Mode::TagDeleteConfirm;
}
}
}
pub fn confirm_tag_delete(&mut self) {
if let Some((tag_name, is_on_remote)) = self.tag_delete_target.take() {
let (repo_path, remotes_len, first_remote) = if let Some(repo::ItemDetail::Repo {
resolved,
info,
}) = &self.current_detail
{
(resolved.clone(), info.remotes.len(), info.remotes.first().map(|r| r.name.clone()))
} else {
return;
};
match repo::delete_tag(&repo_path, &tag_name) {
Ok(()) => {
self.status_message = Some(format!("Deleted local tag '{}'", tag_name));
self.tag_list.local_tag_selection = 0;
self.resync_detail();
if is_on_remote {
if remotes_len > 1 {
self.tag_delete_target = Some((tag_name, true));
self.remote_picker_action = Some(RemotePickerAction::DeleteRemoteTag);
self.remote_picker_selection = 0;
self.mode = Mode::RemotePicker;
return;
} else if let Some(remote_name) = first_remote {
self.execute_delete_remote_tag_on(&tag_name, &remote_name);
}
}
}
Err(e) => {
self.set_error(format!("Failed to delete tag: {}", e));
}
}
}
self.mode = Mode::Detail;
}
pub fn cancel_tag_delete(&mut self) {
self.tag_delete_target = None;
self.mode = Mode::Detail;
}
pub fn request_tag_push(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(tag_info) = info.local_tags.get(self.tag_list.local_tag_selection) {
self.tag_push_force = false;
if info.remotes.len() > 1 {
self.tag_push_target = Some(tag_info.name.clone());
self.remote_picker_action = Some(RemotePickerAction::PushTag);
self.remote_picker_selection = 0;
self.mode = Mode::RemotePicker;
} else {
self.tag_push_target = Some(tag_info.name.clone());
self.mode = Mode::TagPushConfirm;
}
}
}
}
pub fn confirm_tag_push(&mut self) {
if let Some(tag_name) = self.tag_push_target.take() {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
let remote_name = match info.remotes.first().map(|r| r.name.clone()) {
Some(name) => name,
None => {
self.status_message =
Some("No remotes configured for this repository".to_string());
self.mode = Mode::Detail;
return;
}
};
self.execute_tag_push_to(&tag_name, &remote_name);
}
}
self.mode = Mode::Detail;
}
pub fn cancel_tag_push(&mut self) {
self.tag_push_target = None;
self.tag_push_force = false;
self.mode = Mode::Detail;
}
pub fn request_tag_push_all(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if info.remotes.is_empty() {
self.status_message = Some("No remotes configured for this repository".to_string());
return;
}
if info.remotes.len() > 1 {
self.remote_picker_action = Some(RemotePickerAction::PushAllTags);
self.remote_picker_selection = 0;
self.mode = Mode::RemotePicker;
} else {
let remote_name = info
.remotes
.first()
.map(|r| r.name.clone())
.unwrap_or_else(|| "origin".to_string());
self.remote_action_target = Some(remote_name);
self.mode = Mode::TagPushAllConfirm;
}
}
}
pub fn confirm_tag_push_all(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
let remote_name = match self
.remote_action_target
.take()
.or_else(|| info.remotes.first().map(|r| r.name.clone()))
{
Some(name) => name,
None => {
self.status_message =
Some("No remotes configured for this repository".to_string());
self.mode = Mode::Detail;
return;
}
};
self.execute_tag_push_all_to(&remote_name);
}
self.mode = Mode::Detail;
}
pub fn cancel_tag_push_all(&mut self) {
self.remote_action_target = None;
self.mode = Mode::Detail;
}
pub fn start_branch_create(&mut self) {
if let Some(repo::ItemDetail::Repo { .. }) = &self.current_detail {
self.input_buffer.clear();
self.mode = Mode::BranchCreateInput;
}
}
pub fn commit_branch_create(&mut self) {
let branch_name = self.input_buffer.trim().to_string();
if branch_name.is_empty() {
self.status_message = Some("Branch name cannot be empty".to_string());
self.input_buffer.clear();
self.mode = Mode::Detail;
return;
}
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
match repo::create_branch(resolved, &branch_name) {
Ok(()) => {
match repo::checkout_local_branch(resolved, &branch_name) {
Ok(()) => {
self.status_message =
Some(format!("Created and switched to branch '{}'", branch_name));
self.stats.branches_created += 1;
}
Err(e) => {
self.set_error(format!(
"Created branch '{}', but checkout failed: {}",
branch_name, e
));
self.stats.branches_created += 1;
}
}
self.branch_list.local_branch_selection = 0;
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to create branch: {}", e));
}
}
}
self.input_buffer.clear();
self.mode = Mode::Detail;
}
pub fn cancel_branch_create(&mut self) {
self.input_buffer.clear();
self.mode = Mode::Detail;
}
pub fn request_branch_delete(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
match self.detail_focus {
DetailSection::LocalBranches => {
if let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
{
if branch_info.is_head {
self.status_message =
Some("Cannot delete the currently checked out branch".to_string());
return;
}
self.branch_action_target = Some((branch_info.name.clone(), false));
self.mode = Mode::BranchDeleteConfirm;
}
}
DetailSection::RemoteBranches => {
if let Some(branch_info) =
info.remote_branches.get(self.branch_list.remote_branch_selection)
{
self.branch_action_target = Some((branch_info.name.clone(), true));
self.mode = Mode::BranchDeleteConfirm;
}
}
_ => {}
}
}
}
pub fn confirm_branch_delete(&mut self) {
if let Some((branch_name, is_remote)) = self.branch_action_target.take() {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
let repo_path = resolved.clone();
let b_name = branch_name.clone();
if is_remote {
if let Some(pos) = b_name.find('/') {
let remote = b_name[..pos].to_string();
let target_branch = b_name[pos + 1..].to_string();
self.fetching = true;
self.status_message = Some(format!(
"Deleting remote branch '{}' on '{}'...",
target_branch, remote
));
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<(), Box<dyn std::error::Error>> {
let safe_remote = safe_ref(&remote)?;
let safe_target = safe_ref(&target_branch)?;
let mut cmd = git_command();
cmd.arg("push")
.arg(safe_remote)
.arg("--delete")
.arg(safe_target)
.current_dir(&repo_path);
let output = cmd.output()?;
if !output.status.success() {
let err =
String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git push failed: {}", err).into());
}
repo::delete_remote_branch(&repo_path, &b_name)?;
Ok(())
})();
match res {
Ok(()) => {
let _ = tx.send(format!("Deleted remote branch '{}'", b_name));
}
Err(e) => {
let _ =
tx.send(format!("Failed to delete remote branch: {}", e));
}
}
});
} else {
self.status_message = Some("Invalid remote branch name".to_string());
}
} else {
let res = repo::delete_local_branch(&repo_path, &b_name);
match res {
Ok(()) => {
self.status_message = Some(format!("Deleted branch '{}'", b_name));
self.stats.branches_deleted += 1;
self.branch_list.local_branch_selection = 0;
self.branch_list.remote_branch_selection = 0;
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to delete branch: {}", e));
}
}
}
}
}
self.mode = Mode::Detail;
}
pub fn cancel_branch_delete(&mut self) {
self.branch_action_target = None;
self.mode = Mode::Detail;
}
pub fn request_branch_merge(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
match self.detail_focus {
DetailSection::LocalBranches => {
if let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
{
self.branch_action_target = Some((branch_info.name.clone(), false));
self.mode = Mode::BranchMergeConfirm;
}
}
DetailSection::RemoteBranches => {
if let Some(branch_info) =
info.remote_branches.get(self.branch_list.remote_branch_selection)
{
self.branch_action_target = Some((branch_info.name.clone(), true));
self.mode = Mode::BranchMergeConfirm;
}
}
_ => {}
}
}
}
pub fn confirm_branch_merge(&mut self) {
let target = self.branch_action_target.take();
self.mode = Mode::Detail;
if let Some((branch_name, is_remote)) = target {
if let Some(repo::ItemDetail::Repo { resolved, info }) = &self.current_detail {
let current_branch = match info.local_branches.iter().find(|b| b.is_head) {
Some(b) => b.name.clone(),
None => {
self.status_message = Some(
"No checked-out branch (detached HEAD). Cannot merge.".to_string(),
);
return;
}
};
if !is_remote && branch_name == current_branch {
self.status_message = Some("Cannot merge a branch into itself.".to_string());
return;
}
self.fetching = true;
self.status_message =
Some(format!("Merging '{}' into '{}'...", branch_name, current_branch));
let repo_path = resolved.clone();
let target_name = branch_name.clone();
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<String, Box<dyn std::error::Error>> {
let safe_target = safe_ref(&target_name)?;
let output = git_command()
.arg("merge")
.arg(safe_target)
.current_dir(&repo_path)
.output()?;
if output.status.success() {
Ok(format!(
"Merged '{}' into '{}' successfully",
target_name, current_branch
))
} else {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let mut err_msg = if !stderr.is_empty() { stderr } else { stdout };
if err_msg.contains("CONFLICT") {
err_msg = "Merge conflicts detected. Please resolve conflicts."
.to_string();
}
Err(format!("git merge failed: {}", err_msg).into())
}
})();
let msg = match res {
Ok(success) => success,
Err(e) => format!("Merge failed: {}", e),
};
let _ = tx.send(msg);
});
}
}
}
pub fn cancel_branch_merge(&mut self) {
self.branch_action_target = None;
self.mode = Mode::Detail;
}
pub fn request_branch_merge_into(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if self.detail_focus != DetailSection::LocalBranches {
return;
}
let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
else {
return;
};
if info.local_branches.iter().all(|b| !b.is_head) {
self.status_message =
Some("No checked-out branch (detached HEAD). Cannot merge.".to_string());
return;
}
if branch_info.is_head {
self.status_message = Some("Selected branch is already checked out.".to_string());
return;
}
self.branch_action_target = Some((branch_info.name.clone(), false));
self.mode = Mode::BranchMergeIntoConfirm;
}
}
pub fn confirm_branch_merge_into(&mut self) {
let target = self.branch_action_target.take();
self.mode = Mode::Detail;
if let Some((branch_name, _)) = target {
if let Some(repo::ItemDetail::Repo { resolved, info }) = &self.current_detail {
let current_branch = match info.local_branches.iter().find(|b| b.is_head) {
Some(b) => b.name.clone(),
None => {
self.status_message = Some(
"No checked-out branch (detached HEAD). Cannot merge.".to_string(),
);
return;
}
};
if branch_name == current_branch {
self.status_message =
Some("Selected branch is already checked out.".to_string());
return;
}
self.fetching = true;
self.status_message = Some(format!(
"Checking out '{}' and merging '{}' into it...",
branch_name, current_branch
));
let repo_path = resolved.clone();
let target_name = branch_name.clone();
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<String, Box<dyn std::error::Error>> {
let safe_target = safe_ref(&target_name)?;
let safe_source = safe_ref(¤t_branch)?;
let checkout = git_command()
.arg("checkout")
.arg(safe_target)
.current_dir(&repo_path)
.output()?;
if !checkout.status.success() {
let stderr =
String::from_utf8_lossy(&checkout.stderr).trim().to_string();
return Err(format!("git checkout failed: {}", stderr).into());
}
let merge = git_command()
.arg("merge")
.arg(safe_source)
.current_dir(&repo_path)
.output()?;
if merge.status.success() {
Ok(format!(
"Checked out '{}' and merged '{}' into it",
target_name, current_branch
))
} else {
let stdout = String::from_utf8_lossy(&merge.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&merge.stderr).trim().to_string();
let mut err_msg = if !stderr.is_empty() { stderr } else { stdout };
if err_msg.contains("CONFLICT") {
err_msg = "Merge conflicts detected. Please resolve conflicts."
.to_string();
}
Err(format!("git merge failed: {}", err_msg).into())
}
})();
let msg = match res {
Ok(success) => success,
Err(e) => format!("Merge into failed: {}", e),
};
let _ = tx.send(msg);
});
}
}
}
pub fn cancel_branch_merge_into(&mut self) {
self.branch_action_target = None;
self.mode = Mode::Detail;
}
pub fn request_branch_rebase(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if self.detail_focus == DetailSection::LocalBranches {
if let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
{
if !branch_info.is_head {
self.branch_action_target = Some((branch_info.name.clone(), false));
self.mode = Mode::BranchRebaseConfirm;
}
}
} else if self.detail_focus == DetailSection::RemoteBranches {
if let Some(branch_info) =
info.remote_branches.get(self.branch_list.remote_branch_selection)
{
self.branch_action_target = Some((branch_info.name.clone(), true));
self.mode = Mode::BranchRebaseConfirm;
}
}
}
}
pub fn confirm_branch_rebase(&mut self) {
let target = self.branch_action_target.take();
self.mode = Mode::Detail;
if let Some((branch_name, is_remote)) = target {
if let Some(repo::ItemDetail::Repo { resolved, info }) = &self.current_detail {
let current_branch = match info.local_branches.iter().find(|b| b.is_head) {
Some(b) => b.name.clone(),
None => {
self.status_message = Some(
"No checked-out branch (detached HEAD). Cannot rebase.".to_string(),
);
return;
}
};
if !is_remote && branch_name == current_branch {
self.status_message = Some("Cannot rebase a branch onto itself.".to_string());
return;
}
self.fetching = true;
self.status_message =
Some(format!("Rebasing '{}' onto '{}'...", current_branch, branch_name));
let repo_path = resolved.clone();
let target_name = branch_name.clone();
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<String, Box<dyn std::error::Error>> {
let safe_target = safe_ref(&target_name)?;
let output = git_command()
.arg("rebase")
.arg(safe_target)
.current_dir(&repo_path)
.output()?;
if output.status.success() {
Ok(format!(
"Rebased '{}' onto '{}' successfully",
current_branch, target_name
))
} else {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let mut err_msg = if !stderr.is_empty() { stderr } else { stdout };
if err_msg.contains("CONFLICT") || err_msg.contains("conflict") {
err_msg = "Rebase conflicts detected. Please resolve in terminal (git rebase --continue/--abort).".to_string();
}
Err(format!("git rebase failed: {}", err_msg).into())
}
})();
let msg = match res {
Ok(success) => success,
Err(e) => format!("Rebase failed: {}", e),
};
let _ = tx.send(msg);
});
}
}
}
pub fn cancel_branch_rebase(&mut self) {
self.branch_action_target = None;
self.mode = Mode::Detail;
}
pub fn request_branch_interactive_rebase(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if self.detail_focus == DetailSection::LocalBranches {
if let Some(branch_info) =
info.local_branches.get(self.branch_list.local_branch_selection)
{
if !branch_info.is_head {
self.branch_action_target = Some((branch_info.name.clone(), false));
self.mode = Mode::BranchInteractiveRebaseConfirm;
}
}
} else if self.detail_focus == DetailSection::RemoteBranches {
if let Some(branch_info) =
info.remote_branches.get(self.branch_list.remote_branch_selection)
{
self.branch_action_target = Some((branch_info.name.clone(), true));
self.mode = Mode::BranchInteractiveRebaseConfirm;
}
}
}
}
pub fn confirm_branch_interactive_rebase(&mut self) {
let target = self.branch_action_target.take();
self.mode = Mode::Detail;
if let Some((branch_name, _is_remote)) = target {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
self.pending_interactive_rebase = Some((resolved.clone(), branch_name));
}
}
}
pub fn cancel_branch_interactive_rebase(&mut self) {
self.branch_action_target = None;
self.mode = Mode::Detail;
}
pub fn run_interactive_rebase(&mut self) {
if self.is_uncommitted_selected() {
self.status_message =
Some("Cannot run interactive rebase on <uncommitted> row.".to_string());
return;
}
let params = match &self.current_detail {
Some(repo::ItemDetail::Repo { resolved, .. }) => {
self.get_selected_commit().map(|c| (resolved.clone(), c.oid.clone()))
}
_ => None,
};
if let Some((repo_path, commit_oid)) = params {
let is_root = repo::is_root_commit(&repo_path, &commit_oid);
let target = if is_root { "--root".to_string() } else { format!("{}~1", commit_oid) };
self.pending_interactive_rebase = Some((repo_path, target));
}
}
pub fn staging_file_total(&self) -> usize {
match &self.current_detail {
Some(ItemDetail::Repo { info, .. }) => match self.detail_focus {
DetailSection::Staged => info.changes.staged.len(),
DetailSection::Unstaged => info.changes.unstaged.len(),
DetailSection::Conflicts => info.changes.conflicted.len(),
_ => 0,
},
_ => 0,
}
}
pub fn confirm_abort_merge(&mut self) {
self.mode = Mode::Detail;
let repo_path = match &self.current_detail {
Some(ItemDetail::Repo { resolved, .. }) => Some(resolved.clone()),
_ => None,
};
if let Some(repo_path) = repo_path {
match repo::abort_merge(&repo_path) {
Ok(()) => {
self.status_message = Some("Merge aborted successfully".to_string());
self.refresh_detail();
if self.is_conflicted_empty() {
self.detail_focus = DetailSection::Unstaged;
}
self.refresh_staging_diff();
}
Err(e) => self.set_error(format!("Abort merge failed: {}", e)),
}
}
}
pub fn confirm_continue_merge(&mut self) {
self.mode = Mode::Detail;
let repo_path = match &self.current_detail {
Some(ItemDetail::Repo { resolved, .. }) => Some(resolved.clone()),
_ => None,
};
if let Some(repo_path) = repo_path {
match repo::continue_merge(&repo_path) {
Ok(()) => {
self.status_message = Some("Merge continued successfully".to_string());
self.refresh_detail();
if self.is_conflicted_empty() {
self.detail_focus = DetailSection::Unstaged;
}
self.refresh_staging_diff();
}
Err(e) => {
self.set_error(format!("Merge continue failed: {}", e));
self.refresh_detail();
self.refresh_staging_diff();
}
}
}
}
pub fn fetch_remote_tags(&mut self, show_progress: bool) {
if self.fetching {
return;
}
if let Some(repo::ItemDetail::Repo { resolved, info }) = &mut self.current_detail {
info.remote_tags_attempted = true;
let remote = info
.remotes
.get(self.branch_list.remote_selection)
.or_else(|| info.remotes.first());
if let Some(remote) = remote {
let repo_path = resolved.clone();
let remote_name = remote.name.clone();
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
let reason = if show_progress { "user triggered" } else { "implicit refresh" };
let r_name = repo_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url = repo::get_remote_url(&repo_path, &remote_name)
.unwrap_or_else(|| "(no url)".to_string());
crate::debug_log::info(format!(
"Network Action: Fetching remote tags from '{}' for repository '{}' [url: '{}'] (show_progress={}, {})",
remote_name, r_name, remote_url, show_progress, reason
));
if show_progress {
self.fetching = true;
self.status_message = Some(format!("Fetching tags from '{}'...", remote_name));
} else {
self.increment_implicit_network();
}
std::thread::spawn(move || {
match repo::get_remote_tags(&repo_path, &remote_name, timeout) {
Ok(tags) => {
let serialized = repo::serialize_tags(&tags);
let _ = tx.send(format!("REMOTE_TAGS:{}", serialized));
}
Err(e) => {
let _ = tx
.send(format!("REMOTE_TAGS_ERR:Failed to get remote tags: {}", e));
}
}
});
}
}
}
pub fn fetch_remote(&mut self, remote_name: &str) {
if self.fetching {
return;
}
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
let r_name = resolved.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url = repo::get_remote_url(resolved, remote_name)
.unwrap_or_else(|| "(no url)".to_string());
crate::debug_log::info(format!(
"Network Action: Fetching remote '{}' for repository '{}' [url: '{}'] (user triggered)",
remote_name, r_name, remote_url
));
self.fetching = true;
self.status_message = Some(format!("Fetching remote '{}'...", remote_name));
let repo_path = resolved.clone();
let remote_name = remote_name.to_string();
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let res = (|| -> Result<String, Box<dyn std::error::Error>> {
let safe_remote = safe_ref(&remote_name)?;
let mut cmd = git_command();
cmd.arg("fetch").arg(safe_remote).current_dir(&repo_path);
let output = crate::git_cmd::run_git_with_timeout(cmd, timeout)?;
if output.status.success() {
Ok(format!("Fetched remote '{}' successfully", remote_name))
} else {
let err_msg =
crate::fetch_error::sanitize(&String::from_utf8_lossy(&output.stderr));
let failure = crate::fetch_error::classify(&err_msg);
Err(format!("{} — {}", failure.summary(), err_msg).into())
}
})();
let msg = match res {
Ok(success) => success,
Err(e) => format!("Fetch failed: {}", e),
};
let _ = tx.send(msg);
});
}
}
pub fn confirm_remote_picker(&mut self) {
let action = match self.remote_picker_action.take() {
Some(a) => a,
None => {
self.mode = Mode::Detail;
return;
}
};
let remote_name = if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
info.remotes.get(self.remote_picker_selection).map(|r| r.name.clone())
} else {
None
};
let remote_name = match remote_name {
Some(n) => n,
None => {
self.mode = Mode::Detail;
return;
}
};
match action {
RemotePickerAction::PushBranch => {
self.mode = Mode::BranchPushConfirm;
if let Some((ref name, _)) = self.branch_action_target.clone() {
self.execute_branch_push_to(name, &remote_name);
}
self.branch_action_target = None;
self.mode = Mode::Detail;
}
RemotePickerAction::PushTag => {
if let Some(tag_name) = self.tag_push_target.take() {
self.execute_tag_push_to(&tag_name, &remote_name);
}
self.mode = Mode::Detail;
}
RemotePickerAction::PushAllTags => {
self.remote_action_target = Some(remote_name);
self.mode = Mode::TagPushAllConfirm;
}
RemotePickerAction::DeleteRemoteTag => {
if let Some((tag_name, _)) = self.tag_delete_target.take() {
self.execute_delete_remote_tag_on(&tag_name, &remote_name);
}
self.mode = Mode::Detail;
}
RemotePickerAction::FetchRemote => {
self.branch_list.remote_selection = self.remote_picker_selection;
self.fetch_remote(&remote_name);
self.mode = Mode::Detail;
}
}
}
pub fn cancel_remote_picker(&mut self) {
self.remote_picker_action = None;
self.branch_action_target = None;
self.tag_push_target = None;
self.tag_delete_target = None;
self.mode = Mode::Detail;
}
pub fn dismiss_fetch(&mut self) {
self.fetching = false;
self.status_message =
Some("Operation dismissed (may still be running in background)".to_string());
}
pub fn bulk_fetch_all(&mut self) {
if !self.bulk_fetching.is_empty() {
return;
}
crate::debug_log::info("Network Action: Bulk fetching all repositories (user triggered)");
self.spawn_bulk_fetch(false, None);
if !self.bulk_fetching.is_empty() {
self.status_message =
Some(format!("Fetching {} repositories concurrently...", self.bulk_fetching.len()));
}
}
pub fn bulk_fetch_due_implicit(&mut self, due: &std::collections::HashSet<String>) {
if !self.bulk_fetching.is_empty() {
return;
}
crate::debug_log::info(format!(
"Network Action: Bulk fetching {} due repositories (scheduled)",
due.len()
));
self.spawn_bulk_fetch(true, Some(due));
}
pub fn bulk_fetch_summary(&self) -> String {
let round: Vec<&Result<String, String>> = self
.bulk_fetch_round
.iter()
.filter_map(|path| self.bulk_fetch_results.get(path))
.collect();
let failed = round.iter().filter(|r| r.is_err()).count();
let ok = round.len() - failed;
if failed == 0 {
return format!("Bulk fetch completed — {} repositories up to date", ok);
}
let key = self.keybindings.format_action_keys(
crate::keybindings::Action::HomeFetchDetails,
self.config.compatibility_mode,
);
format!("Bulk fetch: {} succeeded, {} failed (press {} for details)", ok, failed, key)
}
pub fn show_fetch_failure_details(&mut self) {
let item = match self.get_selected_item() {
Some(item) => item.clone(),
None => return,
};
let detail = match self.bulk_fetch_results.get(&item) {
Some(Err(err)) => err.clone(),
_ => {
self.status_message =
Some("No fetch error recorded for this repository".to_string());
return;
}
};
let failure = self
.bulk_fetch_failures
.get(&item)
.copied()
.unwrap_or(crate::fetch_error::FetchFailure::Unknown);
let remote = repo::get_default_remote_url(&repo::expand_tilde(&item))
.unwrap_or_else(|| "(no remote configured)".to_string());
self.error_message = Some(format!(
"Fetch failed: {}\n\nRepository: {}\nRemote: {}\n\n{}\n\n--- git output ---\n{}",
failure.label(),
item,
remote,
failure.summary(),
detail
));
}
fn spawn_bulk_fetch(
&mut self,
scheduled: bool,
only: Option<&std::collections::HashSet<String>>,
) {
let origin = if scheduled { "scheduled" } else { "user triggered" };
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
self.bulk_fetch_round.clear();
self.bulk_fetch_completed_at = None;
let items = self.config.items.clone();
for (idx, item) in items.iter().enumerate() {
if let Some(only) = only {
if !only.contains(item) {
continue;
}
}
if !matches!(self.statuses.get(idx), Some(repo::ItemStatus::GitRepo(_))) {
continue;
}
let path_buf = repo::expand_tilde(item);
if !path_buf.exists() {
continue;
}
let path_str = item.clone();
let r_name = path_buf.file_name().and_then(|s| s.to_str()).unwrap_or(&path_str);
let remote_url =
repo::get_default_remote_url(&path_buf).unwrap_or_else(|| "(no url)".to_string());
crate::debug_log::info(format!(
"Network Action: Fetching repository '{}' [url: '{}'] during bulk fetch ({})",
r_name, remote_url, origin
));
self.bulk_fetching.insert(path_str.clone());
self.bulk_fetch_round.insert(path_str.clone());
self.bulk_fetch_results.remove(&path_str);
self.bulk_fetch_failures.remove(&path_str);
self.increment_implicit_network();
let tx = self.tx.clone();
std::thread::spawn(move || {
let mut cmd = crate::git_cmd::git_command();
cmd.arg("fetch").current_dir(&path_buf);
let msg = match crate::git_cmd::run_git_with_timeout(cmd, timeout) {
Ok(out) if out.status.success() => {
format!("BULK_FETCH_SUCCESS:{}", path_str)
}
Ok(out) => {
let err_msg =
crate::fetch_error::sanitize(&String::from_utf8_lossy(&out.stderr));
format!("BULK_FETCH_ERROR:{}|||{}", path_str, err_msg)
}
Err(e) => format!("BULK_FETCH_ERROR:{}|||{}", path_str, e),
};
let _ = tx.send(msg);
});
}
}
fn execute_branch_push_to(&mut self, branch_name: &str, remote_name: &str) {
let push_tags = self.branch_push_tags;
self.branch_push_tags = false;
if self.fetching {
return;
}
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
let repo_path = resolved.clone();
let branch_name = branch_name.to_string();
let remote_name = remote_name.to_string();
let r_name = repo_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url = repo::get_remote_url(&repo_path, &remote_name)
.unwrap_or_else(|| "(no url)".to_string());
let tags_desc = if push_tags { " (with tags)" } else { "" };
crate::debug_log::info(format!(
"Network Action: Pushing branch '{}'{} to '{}' for repository '{}' [url: '{}'] (user triggered)",
branch_name, tags_desc, remote_name, r_name, remote_url
));
self.fetching = true;
self.status_message =
Some(format!("Pushing '{}'{} to '{}'...", branch_name, tags_desc, remote_name));
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let safe_remote = match safe_ref(&remote_name) {
Ok(r) => r,
Err(e) => {
let _ = tx.send(format!("Invalid remote: {}", e));
return;
}
};
let safe_branch = match safe_ref(&branch_name) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(format!("Invalid branch: {}", e));
return;
}
};
let mut cmd = git_command();
cmd.arg("push").arg("-u");
if push_tags {
cmd.arg("--tags");
}
cmd.arg(safe_remote).arg(safe_branch).current_dir(&repo_path);
let output = match cmd.output() {
Ok(o) => o,
Err(e) => {
let _ = tx.send(format!("Failed to run git push: {}", e));
return;
}
};
if output.status.success() {
let tag_suffix = if push_tags { " (with tags)" } else { "" };
let _ = tx.send(format!(
"Pushed '{}'{} to '{}' successfully",
branch_name, tag_suffix, remote_name
));
} else {
let _ = tx.send(format!(
"Failed to push: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
});
}
}
fn execute_tag_push_to(&mut self, tag_name: &str, remote_name: &str) {
let force = self.tag_push_force;
self.tag_push_force = false;
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
let repo_path = resolved.clone();
let tag_name = tag_name.to_string();
let remote_name = remote_name.to_string();
let r_name = repo_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url = repo::get_remote_url(&repo_path, &remote_name)
.unwrap_or_else(|| "(no url)".to_string());
let action_desc = if force { "Force pushing" } else { "Pushing" };
crate::debug_log::info(format!(
"Network Action: {} tag '{}' to '{}' for repository '{}' [url: '{}'] (user triggered)",
action_desc, tag_name, remote_name, r_name, remote_url
));
self.fetching = true;
self.status_message =
Some(format!("{} tag '{}' to '{}'...", action_desc, tag_name, remote_name));
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let safe_remote = match safe_ref(&remote_name) {
Ok(r) => r,
Err(e) => {
let _ = tx.send(format!("Invalid remote: {}", e));
return;
}
};
let safe_tag = match safe_ref(&tag_name) {
Ok(t) => t,
Err(e) => {
let _ = tx.send(format!("Invalid tag: {}", e));
return;
}
};
let mut cmd = git_command();
cmd.arg("push").arg(safe_remote).arg(safe_tag);
if force {
cmd.arg("--force");
}
cmd.current_dir(&repo_path);
let output = match crate::git_cmd::run_git_with_timeout(cmd, timeout) {
Ok(o) => o,
Err(e) => {
let _ = tx.send(format!("Failed to run git push: {}", e));
return;
}
};
if output.status.success() {
let verb = if force { "Force pushed" } else { "Pushed" };
let _ = tx.send(format!(
"{} tag '{}' to '{}' successfully",
verb, tag_name, remote_name
));
} else {
let _ = tx.send(format!(
"Failed to push tag: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
});
}
}
fn execute_tag_push_all_to(&mut self, remote_name: &str) {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
let repo_path = resolved.clone();
let remote_name = remote_name.to_string();
let r_name = repo_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let remote_url = repo::get_remote_url(&repo_path, &remote_name)
.unwrap_or_else(|| "(no url)".to_string());
crate::debug_log::info(format!(
"Network Action: Pushing all tags to '{}' for repository '{}' [url: '{}'] (user triggered)",
remote_name, r_name, remote_url
));
self.fetching = true;
self.status_message = Some(format!("Pushing all tags to '{}'...", remote_name));
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
let safe_remote = match safe_ref(&remote_name) {
Ok(r) => r,
Err(e) => {
let _ = tx.send(format!("Invalid remote: {}", e));
return;
}
};
let mut cmd = git_command();
cmd.arg("push").arg(safe_remote).arg("--tags").current_dir(&repo_path);
let output = match crate::git_cmd::run_git_with_timeout(cmd, timeout) {
Ok(o) => o,
Err(e) => {
let _ = tx.send(format!("Failed to run git push: {}", e));
return;
}
};
if output.status.success() {
let _ = tx.send(format!("Pushed all tags to '{}' successfully", remote_name));
} else {
let _ = tx.send(format!(
"Failed to push tags: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
});
}
}
fn execute_delete_remote_tag_on(&mut self, tag_name: &str, remote_name: &str) {
let repo_path = if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail
{
resolved.clone()
} else {
return;
};
let tag_name = tag_name.to_string();
let remote_name = remote_name.to_string();
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
self.fetching = true;
self.status_message = Some(format!("Deleting remote tag '{}'...", tag_name));
std::thread::spawn(move || {
match repo::delete_remote_tag(&repo_path, &remote_name, &tag_name, timeout) {
Ok(()) => {
let _ = tx.send(format!("Deleted remote tag '{}'", tag_name));
}
Err(e) => {
let _ = tx.send(format!("Failed to delete remote tag: {}", e));
}
}
});
}
pub fn request_stash_delete(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(stash) = info.stashes.get(self.stash_list.stash_selection) {
self.stash_action_target = Some((stash.commit_id.clone(), stash.message.clone()));
self.mode = Mode::StashDeleteConfirm;
}
}
}
pub fn confirm_stash_delete(&mut self) {
if let Some((target_commit_id, target_msg)) = self.stash_action_target.take() {
if let Some(repo::ItemDetail::Repo { resolved, info }) = &self.current_detail {
if let Some(stash) = info
.stashes
.iter()
.find(|s| s.commit_id == target_commit_id && s.message == target_msg)
{
let index_to_delete = stash.index;
match repo::delete_stash(resolved, index_to_delete) {
Ok(()) => {
self.status_message =
Some(format!("Deleted stash@{{{}}}", index_to_delete));
self.stash_list.stash_selection = 0;
self.stash_list.stash_file_selection = 0;
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to delete stash: {}", e));
}
}
} else {
self.status_message =
Some("Failed to delete stash: Stash no longer exists".to_string());
}
}
}
self.mode = Mode::Detail;
}
pub fn cancel_stash_delete(&mut self) {
self.stash_action_target = None;
self.mode = Mode::Detail;
}
pub fn request_stash_apply(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(stash) = info.stashes.get(self.stash_list.stash_selection) {
self.stash_action_target = Some((stash.commit_id.clone(), stash.message.clone()));
self.stash_apply_delete_after = true;
self.mode = Mode::StashApplyConfirm;
}
}
}
pub fn confirm_stash_apply(&mut self) {
if let Some((target_commit_id, target_msg)) = self.stash_action_target.take() {
if let Some(repo::ItemDetail::Repo { resolved, info }) = &self.current_detail {
if let Some(stash) = info
.stashes
.iter()
.find(|s| s.commit_id == target_commit_id && s.message == target_msg)
{
let index_to_apply = stash.index;
match repo::apply_stash(resolved, index_to_apply) {
Ok(()) => {
let mut success_msg = format!("Applied stash@{{{}}}", index_to_apply);
if self.stash_apply_delete_after {
match repo::delete_stash(resolved, index_to_apply) {
Ok(()) => {
success_msg.push_str(" and deleted it");
}
Err(e) => {
success_msg
.push_str(&format!(", but failed to delete it: {}", e));
}
}
}
self.status_message = Some(success_msg);
self.stash_list.stash_selection = 0;
self.stash_list.stash_file_selection = 0;
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to apply stash: {}", e));
}
}
} else {
self.status_message =
Some("Failed to apply stash: Stash no longer exists".to_string());
}
}
}
self.mode = Mode::Detail;
}
pub fn toggle_stash_apply_delete(&mut self) {
self.stash_apply_delete_after = !self.stash_apply_delete_after;
}
pub fn cancel_stash_apply(&mut self) {
self.mode = Mode::Detail;
}
pub fn request_tag_checkout(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(tag_info) = info.local_tags.get(self.tag_list.local_tag_selection) {
self.tag_checkout_target = Some(tag_info.name.clone());
self.mode = Mode::TagCheckoutConfirm;
}
}
}
pub fn confirm_tag_checkout(&mut self) {
if let Some(tag_name) = self.tag_checkout_target.take() {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
match repo::checkout_tag(resolved, &tag_name) {
Ok(()) => {
self.status_message =
Some(format!("Checked out tag '{}' (detached HEAD)", tag_name));
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to checkout tag: {}", e));
}
}
}
}
self.mode = Mode::Detail;
}
pub fn cancel_tag_checkout(&mut self) {
self.tag_checkout_target = None;
self.mode = Mode::Detail;
}
pub fn request_checkout_selected_commit(&mut self) {
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let Some(commit) = info.commits.get(self.commit_list.selection) {
self.commit_action_target_oid = Some(commit.id.clone());
self.mode = Mode::CommitCheckoutConfirm;
}
}
}
pub fn confirm_commit_checkout(&mut self) {
if let Some(oid) = self.commit_action_target_oid.take() {
if let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail {
match repo::checkout_commit(resolved, &oid) {
Ok(()) => {
self.status_message = Some(format!(
"Checked out commit '{}' (detached HEAD)",
&oid[0..7.min(oid.len())]
));
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to checkout commit: {}", e));
}
}
}
}
self.mode = Mode::Detail;
}
pub fn cancel_commit_checkout(&mut self) {
self.commit_action_target_oid = None;
self.mode = Mode::Detail;
}
pub fn confirm_forge_checkout(&mut self) {
self.mode = Mode::Detail;
let Some(target) = self.forge_checkout_target.take() else {
return;
};
let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail else {
return;
};
let path = resolved.clone();
let tx = self.tx.clone();
self.fetching = true;
match target {
crate::app::ForgeCheckoutTarget::Issue { number, .. } => {
self.status_message =
Some(format!("Resolving and switching branch for issue #{}...", number));
std::thread::spawn(move || {
let msg = match repo::resolve_and_checkout_issue_branch(&path, number) {
Ok(msg) => format!("CHECKOUT_SUCCESS:{}", msg),
Err(e) => format!("CHECKOUT_ERROR:Failed to switch branch: {}", e),
};
let _ = tx.send(msg);
});
}
crate::app::ForgeCheckoutTarget::PullRequest { number, .. } => {
self.status_message = Some(format!("Checking out branch for PR #{}...", number));
std::thread::spawn(move || {
let msg = match repo::checkout_pr_branch(&path, number) {
Ok(msg) => format!("CHECKOUT_SUCCESS:{}", msg),
Err(e) => format!("CHECKOUT_ERROR:Failed to switch branch: {}", e),
};
let _ = tx.send(msg);
});
}
}
}
pub fn cancel_forge_checkout(&mut self) {
self.forge_checkout_target = None;
self.mode = Mode::Detail;
}
pub fn commit_worktree_add_branch(&mut self) {
let branch = self.input_buffer.trim().to_string();
if branch.is_empty() {
self.status_message = Some("Branch name cannot be empty".to_string());
self.mode = Mode::Detail;
return;
}
self.worktree_add_branch = branch;
self.input_buffer.clear();
self.mode = Mode::WorktreeAddPathInput;
}
pub fn commit_worktree_add_path(&mut self) {
let path_str = self.input_buffer.trim().to_string();
if path_str.is_empty() {
self.status_message = Some("Path cannot be empty".to_string());
self.mode = Mode::Detail;
return;
}
let wt_path = repo::expand_tilde(&path_str);
let resolved_path = match &self.current_detail {
Some(repo::ItemDetail::Repo { resolved, .. }) => resolved.clone(),
_ => {
self.mode = Mode::Detail;
return;
}
};
match repo::worktree_add(&resolved_path, &self.worktree_add_branch, &wt_path) {
Ok(created) => {
self.status_message = Some(if created {
format!("Worktree added on new branch '{}'", self.worktree_add_branch)
} else {
"Worktree added successfully".to_string()
});
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to add worktree: {}", e));
}
}
self.input_buffer.clear();
self.mode = Mode::Detail;
}
pub fn commit_worktree_lock_reason(&mut self) {
let reason = self.input_buffer.trim().to_string();
let resolved_path = match &self.current_detail {
Some(repo::ItemDetail::Repo { resolved, .. }) => resolved.clone(),
_ => {
self.mode = Mode::Detail;
return;
}
};
if let Some(repo::ItemDetail::Repo { info, .. }) = &self.current_detail {
if let repo::TabData::Loaded(wts) = &info.worktrees {
if let Some(wt) = wts.get(self.worktree_selection) {
match repo::worktree_lock(&resolved_path, &wt.path, &reason) {
Ok(_) => {
self.status_message = Some("Worktree locked successfully".to_string());
self.resync_detail();
}
Err(e) => {
self.set_error(format!("Failed to lock: {}", e));
}
}
}
}
}
self.input_buffer.clear();
self.mode = Mode::Detail;
}
pub fn remove_worktree(&mut self, force: bool) {
self.mode = Mode::Detail;
let Some(wt) = self.worktree_remove_target.take() else {
return;
};
let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail else {
return;
};
match repo::worktree_remove(resolved, &wt.path, force) {
Ok(_) => {
self.status_message = Some(format!("Worktree '{}' removed", wt.name));
self.resync_detail();
}
Err(e) => {
let hint = if force {
""
} else {
" (choose 2 to force-remove it, discarding its uncommitted changes)"
};
self.set_error(format!("Failed to remove worktree: {}{}", e.trim(), hint));
}
}
}
pub fn commit_worktree_remove(&mut self) {
let choice = self.input_buffer.trim().to_string();
self.input_buffer.clear();
self.mode = Mode::Detail;
match choice.as_str() {
"1" => {
self.remove_worktree(false);
}
"2" => {
self.remove_worktree(true);
}
_ => {
self.worktree_remove_target = None;
self.status_message =
Some("Invalid selection. Type 1 or 2 to remove worktree.".to_string());
}
}
}
pub fn commit_submodule_add_url(&mut self) {
self.submodule_add_url = self.input_buffer.trim().to_string();
self.input_buffer.clear();
self.mode = Mode::SubmoduleAddPathInput;
}
pub fn commit_submodule_add_path(&mut self) {
self.submodule_add_path = self.input_buffer.trim().to_string();
self.input_buffer.clear();
self.start_submodule_add();
}
pub fn start_submodule_add(&mut self) {
let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail else {
self.mode = Mode::Detail;
return;
};
let repo_path = resolved.clone();
let url = self.submodule_add_url.clone();
let path = self.submodule_add_path.clone();
let r_name = repo_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
crate::debug_log::info(format!(
"Network Action: Adding submodule from '{}' to '{}' in repository '{}' (user triggered)",
url, path, r_name
));
self.fetching = true;
self.status_message = Some(format!("Adding submodule '{}'...", path));
self.mode = Mode::Detail;
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
let timeout = std::time::Duration::from_secs(self.config.fetch_timeout_secs);
std::thread::spawn(move || {
let trimmed_url = url.trim().to_lowercase();
if trimmed_url.contains("ext:") || trimmed_url.contains("fd:") {
let _ = tx.send("Error: Malicious URL protocol rejected".to_string());
return;
}
let mut cmd = git_command();
cmd.arg("submodule").arg("add").arg("--").arg(&url).arg(&path).current_dir(&repo_path);
match crate::git_cmd::run_git_with_timeout(cmd, timeout) {
Ok(out) if out.status.success() => {
let nested = std::path::Path::new(&path);
let msg = match repo::submodule_update(&repo_path, Some(nested), timeout) {
Ok(()) => format!("Submodule '{}' added successfully", path),
Err(e) => format!(
"Submodule '{}' added, but its nested submodules failed to initialize: {}",
path, e
),
};
let _ = tx.send(msg);
}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr).to_string();
let clean_err = err.trim().replace('\n', " ");
let _ = tx.send(format!("Failed to add submodule: {}", clean_err));
}
Err(e) => {
let _ = tx.send(format!("Failed to run git submodule add: {}", e));
}
}
});
}
pub fn confirm_submodule_delete(&mut self) {
let Some(repo::ItemDetail::Repo { resolved, .. }) = &self.current_detail else {
self.mode = Mode::Detail;
return;
};
let Some(sub) = self.submodule_delete_target.take() else {
self.mode = Mode::Detail;
return;
};
let repo_path = resolved.clone();
let label = sub.path.display().to_string();
self.fetching = true;
self.status_message = Some(format!("Removing submodule '{}'...", label));
self.mode = Mode::Detail;
let tx = RepoSender { tx: self.tx.clone(), path: repo_path.clone() };
std::thread::spawn(move || {
match repo::submodule_remove(&repo_path, &sub.name, &sub.path) {
Ok(()) => {
let _ = tx.send(format!("Submodule '{}' removed successfully", label));
}
Err(e) => {
let _ = tx.send(format!("Failed to remove submodule '{}': {}", label, e));
}
}
});
}
pub fn cancel_submodule_delete(&mut self) {
self.submodule_delete_target = None;
self.mode = Mode::Detail;
}
}
fn git_command() -> std::process::Command {
crate::git_cmd::git_command()
}
fn safe_ref(r: &str) -> Result<&str, String> {
let trimmed = r.trim();
if trimmed.starts_with('-') {
return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
}
if trimmed.is_empty() {
return Err("Ref name cannot be empty".to_string());
}
Ok(trimmed)
}