fix(scm): answer a branchless push and name why a commit is refused (#545, #546) (#576)

* fix(scm): answer a push that has no branch to move (#545)

A click on Push at a detached HEAD died in scm_push's let-else without a
word, and it was not the only path that did: the key binding, the
palette entry and the follow-up half of "Commit and Push" — which lands
in scm_push after the commit has already succeeded — all share that
guard, so a compound verb read as pushed when only the commit happened.
git_data.rs says out loud why that cannot stand: a swallowed click on
Push looks exactly like a push that finished instantly, which is why the
busy slot toasts ScmNetworkBusy instead of dropping the click.

The sync tile made it worse by promising "Publish Branch" — upstream is
None at a detached HEAD by definition, and publishing is the one thing
it cannot do. The tile and the branch menu's Push item now disable
themselves with a tooltip that says why (Fetch works from any HEAD and
Pull already fails loud out of git's own error, so they stay), and the
guard itself toasts the reason for every path that can still reach it.

The guard also swallowed HeadState::Unborn — a branch with a name but
no commits yet — which now gets its own answer rather than the detached
one. Both dead ends are decided in one pure helper, pushable_branch, so
the tile, the menu and the toast cannot drift apart, with a test pinning
what each head state says.

* fix(scm): name the real reason a commit is refused (#546)

Committing with staged work but a blank message was answered with
"Nothing to commit" whatever the actual blocker, because scm_commit
hard-coded that one key for every disabled plan. The panel's own button
gets away with a shared tooltip because it is disabled and the reason
shows on hover; the palette entry and the key binding have nothing to
hover, so their toast was the whole feedback — and it pointed at the
index when the message box was the problem, sending the user staging
files they already staged.

The toast now carries the commit plan's own reason, the same key the
button's tooltip uses, and the commit_plan test pins the split:
whitespace-only message on staged work is ScmCommitNeedsMessage, a
clean tree is ScmNothingToCommit.

* fix(scm): ask pushable_branch for the tile and the menu too

The helper was introduced so the tile, the branch menu and the toast
could not answer differently about the same HEAD, but the first two were
still deciding off their own `detached` check — so an unborn branch,
which the helper already answers with "no commits to push yet", kept a
live sync tile promising "Publish Branch" and a live Push item, and
learned the truth only from the toast after the click. Both now ask the
same helper, and the tooltip carries whichever reason it gives back.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Hongwei Qin
2026-08-13 15:01:03 +08:00
committed by GitHub
co-authored by l0ng-ai
parent bed3c856ed
commit f237cf5c48
7 changed files with 125 additions and 22 deletions
+13
View File
@@ -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
+2
View File
@@ -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",
+4
View File
@@ -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 => "プッシュ",
+2
View File
@@ -787,6 +787,8 @@ l10n_keys! {
ScmUnrepresentablePath,
ScmPublishBranch,
ScmDetached,
ScmPushDetached,
ScmPushNoCommits,
ScmAmendBadge,
ScmSync,
ScmPush,
+2
View File
@@ -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 => "推送",
+21 -11
View File
@@ -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(
+81 -11
View File
@@ -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(