diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d0ac744..c7362913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 came up, and a path on a machine nothing here has a link to — or one a pane's shell has `ssh`'d away to — is shown in full rather than against a home that is not its own. (#580) +- **Push at a detached HEAD no longer fails silently** — the sync tile claimed + "Publish Branch" (the one thing a detached HEAD cannot do) and swallowed the + click, and the key binding, palette and "Commit and Push" follow-up died in + the same guard just as quietly. The tile and the branch menu's Push item now + disable themselves with a tooltip that says why, every other path gets a + toast naming the reason, and an unborn branch — no commits to send yet — + gets its own answer instead of sharing the detached one's silence. (#545) +- **A refused commit says why, not always "Nothing to commit"** — committing + from the palette or the key binding with staged work but a blank message was + answered with "Nothing to commit", which sends the user staging files they + already staged; the toast now carries the plan's actual reason ("Write a + commit message first"), the same words the panel's own button shows on its + tooltip. (#546) ## [26.8.3] - 2026-08-12 diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 9a14c0c3..74fe3578 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1024,6 +1024,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::ScmPublishBranch => "Publish Branch", L10nKey::ScmDetached => "detached", + L10nKey::ScmPushDetached => "Detached HEAD — check out a branch to push", + L10nKey::ScmPushNoCommits => "No commits to push yet", L10nKey::ScmAmendBadge => "amend", L10nKey::ScmSync => "Sync Changes", L10nKey::ScmPush => "Push", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 7d9e48d4..5de9000c 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1078,6 +1078,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::ScmPublishBranch => "ブランチを公開", L10nKey::ScmDetached => "デタッチ", + L10nKey::ScmPushDetached => { + "HEAD がデタッチされています — ブランチをチェックアウトしてからプッシュしてください" + } + L10nKey::ScmPushNoCommits => "プッシュするコミットがまだありません", L10nKey::ScmAmendBadge => "修正", L10nKey::ScmSync => "変更を同期", L10nKey::ScmPush => "プッシュ", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ab912387..ea71fb43 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -787,6 +787,8 @@ l10n_keys! { ScmUnrepresentablePath, ScmPublishBranch, ScmDetached, + ScmPushDetached, + ScmPushNoCommits, ScmAmendBadge, ScmSync, ScmPush, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index a102adb7..8d19a25a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -971,6 +971,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmUnrepresentablePath => "该路径不是合法的 UTF-8,无法传给 git —— 仅可查看。", L10nKey::ScmPublishBranch => "发布分支", L10nKey::ScmDetached => "游离头指针", + L10nKey::ScmPushDetached => "HEAD 游离——请先切换到一个分支再推送", + L10nKey::ScmPushNoCommits => "还没有可推送的提交", L10nKey::ScmAmendBadge => "修订", L10nKey::ScmSync => "同步更改", L10nKey::ScmPush => "推送", diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index 07f59a2a..20cbcd0c 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -7,7 +7,6 @@ use gpui::{Context, PromptLevel, Window}; use tty7_core::core::git::ops::{Destructive, GitOp, PullMode}; -use tty7_core::core::git::status::HeadState; use crate::core::config::DiffViewMode; use crate::ui::app::Tty7App; @@ -211,11 +210,13 @@ impl Tty7App { let message = self.scm_message(&repo, cx); let plan = crate::ui::scm::panel::commit_plan(&status, amend, &message); if !plan.enabled { - gpui_component::WindowExt::push_notification( - window, - t(L10nKey::ScmNothingToCommit).to_string(), - cx, - ); + // The panel's own button exposes this through disabled + tooltip; + // the palette and the key binding have no tooltip to hover, so + // the toast has to carry the reason itself — "write a message + // first" and "nothing to commit" call for opposite actions, and + // answering the first with the second sends the user staging + // files they already staged (#546). + gpui_component::WindowExt::push_notification(window, t(plan.reason).to_string(), cx); // The follow-up dies with the commit: "commit and push" with // nothing to commit must not push whatever the branch holds. return; @@ -338,14 +339,23 @@ impl Tty7App { let Some(status) = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) else { return; }; - let HeadState::Branch { name, .. } = &status.head else { - // A detached HEAD has no branch to push, and pushing a bare sha - // needs a refspec the panel has no way to ask for. - return; + let name = match crate::ui::scm::panel::pushable_branch(&status.head) { + Ok(name) => name.to_string(), + Err(reason) => { + // "A swallowed click on Push looks exactly like a push that + // finished instantly" — git_data.rs says it out loud about + // the busy slot, and the same holds here. The tile and the + // menu disable themselves, but the key binding, the palette + // and the follow-up half of "Commit and Push" all land in + // this guard, so the toast is the only place they can say + // why nothing moved (#545). + gpui_component::WindowExt::push_notification(window, t(reason).to_string(), cx); + return; + } }; let (remote, branch) = match status.upstream.as_deref().and_then(split_upstream) { Some((remote, branch)) => (remote.to_string(), branch.to_string()), - None => ("origin".to_string(), name.clone()), + None => ("origin".to_string(), name), }; let set_upstream = status.upstream.is_none(); self.scm_op( diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 20b9189e..e565c1fb 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -303,6 +303,9 @@ impl Tty7App { let theme = cx.theme(); let (warning, muted, fg) = (theme.warning, theme.muted_foreground, theme.foreground); let detached = matches!(status.head, HeadState::Detached { .. }); + // The same helper `scm_push`'s guard asks, so the tile and the toast + // cannot answer differently about the same HEAD. + let unpushable = pushable_branch(&status.head).err(); let busy = self.scm_network_busy(repo, cx); let others = self.scm_other_repos(repo); @@ -401,11 +404,18 @@ impl Tty7App { cx, ) .rounded_md() - .disabled(busy) - .tooltip(if status.upstream.is_some() { - t(L10nKey::ScmSync) - } else { - t(L10nKey::ScmPublishBranch) + // With no branch to move the tile could only ever lose (#545): + // upstream is None at a detached or unborn HEAD by definition, + // so sync degenerates into scm_push, which has nothing to + // send. Say that on the tooltip instead of promising "Publish + // Branch" — the one thing a detached HEAD cannot do — and let + // the key binding, palette and compound-verb paths toast the + // same reason from inside scm_push. + .disabled(busy || unpushable.is_some()) + .tooltip(match unpushable { + Some(reason) => t(reason), + None if status.upstream.is_some() => t(L10nKey::ScmSync), + None => t(L10nKey::ScmPublishBranch), }) .on_click(cx.listener(|this, _, window, cx| { this.run_scm_action(ScmIntent::Sync, window, cx); @@ -503,6 +513,7 @@ impl Tty7App { .map(|(_, names)| names.clone()) .unwrap_or_default(); let others = self.scm_repo_choices(); + let unpushable = pushable_branch(&status.head).is_err(); move |menu, _window, _cx| { let mut menu = menu.min_w(px(200.)); @@ -547,12 +558,21 @@ impl Tty7App { (L10nKey::ScmPull, ScmIntent::Pull), (L10nKey::ScmPush, ScmIntent::Push), ] { - menu = menu.item(PopupMenuItem::new(t(label)).on_click({ - let app = app.clone(); - move |_, window, cx| { - let _ = app.update(cx, |this, cx| this.run_scm_action(intent, window, cx)); - } - })); + menu = menu.item( + PopupMenuItem::new(t(label)) + // Fetch works from any HEAD, and Pull fails loud out + // of git itself; a Push from a HEAD with no branch to + // move is the one item that could only die in + // scm_push's guard (#545). + .disabled(unpushable && matches!(intent, ScmIntent::Push)) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app + .update(cx, |this, cx| this.run_scm_action(intent, window, cx)); + } + }), + ); } if others.len() > 1 { menu = menu @@ -1911,6 +1931,22 @@ pub(crate) fn commit_stages_everything(status: &WorkingTreeStatus, amend: bool) !amend && status.staged().next().is_none() } +/// The branch a push would move, or why there is no push to make. +/// +/// Both dead ends used to die in the same silent `let-else` in `scm_push` +/// (#545): a detached HEAD has no branch to push — and pushing a bare sha +/// needs a refspec the panel has no way to ask for — while an unborn branch +/// has a name but no commits to send yet. The key binding, the palette and +/// the follow-up half of "Commit and Push" all land in that guard, so the +/// reason is a key the caller can toast, not a log line. +pub(crate) fn pushable_branch(head: &HeadState) -> Result<&str, L10nKey> { + match head { + HeadState::Branch { name, .. } => Ok(name), + HeadState::Detached { .. } => Err(L10nKey::ScmPushDetached), + HeadState::Unborn { .. } => Err(L10nKey::ScmPushNoCommits), + } +} + /// What a row's buttons do. Named rather than inlined because the same verb /// appears on the row, on its group header and in its context menu, and the /// three must not drift into meaning different things. @@ -2361,6 +2397,17 @@ mod tests { !commit_plan(&staged, false, " ").enabled, "an all-whitespace message is no message" ); + // ...and the reason must say so: staged work with a blank message is + // not "nothing to commit", which is what the palette path used to + // answer (#546). + assert_eq!( + commit_plan(&staged, false, " ").reason, + L10nKey::ScmCommitNeedsMessage + ); + assert_eq!( + commit_plan(&clean, false, "msg").reason, + L10nKey::ScmNothingToCommit + ); assert!( commit_plan(&clean, true, "").enabled, "amending with no message keeps HEAD's own with --no-edit" @@ -2368,6 +2415,29 @@ mod tests { assert!(!commit_plan(&clean, false, "msg").enabled); } + #[test] + fn a_head_without_a_pushable_branch_says_why() { + let branch = HeadState::Branch { + name: "main".into(), + oid: "1111111".into(), + }; + assert_eq!(pushable_branch(&branch), Ok("main")); + // The two states the old let-else in scm_push swallowed without a + // word (#545): the toast now names each one. + assert_eq!( + pushable_branch(&HeadState::Detached { + oid: "1111111".into() + }), + Err(L10nKey::ScmPushDetached) + ); + assert_eq!( + pushable_branch(&HeadState::Unborn { + branch: "main".into() + }), + Err(L10nKey::ScmPushNoCommits) + ); + } + #[test] fn only_a_commit_with_an_empty_index_sweeps_the_working_tree() { let staged = status_of_repo(