Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ The VS Code / Cursor extension has its own changelog at
separately — desktop releases are tagged `app-v*`, extension releases `ext-v*` —
but they share the same engine, so most Git behaviour lands in both at once.

## [Unreleased]
## [2.5.0] - 2026-10-01

### Added
- **GitStudio in 14 languages.** Settings ▸ Appearance ▸ Language picks one,
or follows your OS: Simplified and Traditional Chinese, Japanese, Korean, German, French, Spanish, Italian, Portuguese (Brazil), Russian, Turkish, Polish and Czech, besides English. A change applies after a
restart. Translations other than English are machine drafts — corrections
are welcome on GitHub.
- **Edit message… on a commit.** Right-click a commit in the graph to rewrite
its message: the whole message opens in an editor, the commits after it are
replayed, branches on them can come along, and Undo puts it back.

## [2.4.0] - 2026-09-29

Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@
"Already cloned at {0}": "Already cloned at {0}",
"Already merged in the file, outside the conflict markers (by git, or by hand): check it": "Already merged in the file, outside the conflict markers (by git, or by hand): check it",
"Already on {0}": "Already on {0}",
"Already pushed. Editing its message would rewrite history other people have. The next push will need to be a force push.": "Already pushed. Editing its message would rewrite history other people have. The next push will need to be a force push.",
"Already pushed. {0} it would rewrite history other people have.": "Already pushed. {0} it would rewrite history other people have.",
"Already rebased commits": "Already rebased commits",
"Already rebased commits and commits from {0}": "Already rebased commits and commits from {0}",
Expand Down Expand Up @@ -702,6 +703,7 @@
"Commit graph": "Commit graph",
"Commit list": "Commit list",
"Commit message": "Commit message",
"Commit message changed.": "Commit message changed.",
"Commit not in the local clone": "Commit not in the local clone",
"Commit or stash {0}, then pull again.": "Commit or stash {0}, then pull again.",
"Commit page: filter the changed files": "Commit page: filter the changed files",
Expand Down Expand Up @@ -824,6 +826,8 @@
"Couldn't bring the conflict in {0} back.": "Couldn't bring the conflict in {0} back.",
"Couldn't bring them back.": "Couldn't bring them back.",
"Couldn't cancel the run.": "Couldn't cancel the run.",
"Couldn't change the commit message.": "Couldn't change the commit message.",
"Couldn't change the commit message: {0}": "Couldn't change the commit message: {0}",
"Couldn't change {0}.": "Couldn't change {0}.",
"Couldn't check for updates.": "Couldn't check for updates.",
"Couldn't check out the PR.": "Couldn't check out the PR.",
Expand Down Expand Up @@ -1272,12 +1276,17 @@
"Duration": "Duration",
"Edit": "Edit",
"Edit #{0}": "Edit #{0}",
"Edit and move those branches": "Edit and move those branches",
"Edit assignees": "Edit assignees",
"Edit comment": "Edit comment",
"Edit commit message": "Edit commit message",
"Edit gist": "Edit gist",
"Edit issue #{0}": "Edit issue #{0}",
"Edit issue title and description": "Edit issue title and description",
"Edit labels": "Edit labels",
"Edit message": "Edit message",
"Edit message…": "Edit message…",
"Edit on this branch only": "Edit on this branch only",
"Edit pull request #{0}": "Edit pull request #{0}",
"Edit pull request title and description": "Edit pull request title and description",
"Edit release": "Edit release",
Expand All @@ -1288,6 +1297,8 @@
"Edit title & description": "Edit title & description",
"Edit {0}": "Edit {0}",
"Edited {0}": "Edited {0}",
"Editing the message hit a conflict while replaying a later commit. Resolve it and continue the rebase — or abort to put the branch back as it was.": "Editing the message hit a conflict while replaying a later commit. Resolve it and continue the rebase — or abort to put the branch back as it was.",
"Editing the message stopped and needs you — continue the rebase, or abort it to put the branch back as it was.": "Editing the message stopped and needs you — continue the rebase, or abort it to put the branch back as it was.",
"Editors": "Editors",
"Email": "Email",
"Empty file": "Empty file",
Expand Down Expand Up @@ -1913,6 +1924,7 @@
"Move down": "Move down",
"Move down (Alt+Down)": "Move down (Alt+Down)",
"Move or delete what's inside it first": "Move or delete what's inside it first",
"Move the branches on rewritten commits too?": "Move the branches on rewritten commits too?",
"Move the selected commits": "Move the selected commits",
"Move the selected commits down": "Move the selected commits down",
"Move the selected commits down (Alt+Down)": "Move the selected commits down (Alt+Down)",
Expand Down Expand Up @@ -2532,6 +2544,7 @@
"Pushing…": "Pushing…",
"Push…": "Push…",
"Put the clone folder back": "Put the clone folder back",
"Put the old message back": "Put the old message back",
"Put the stash back": "Put the stash back",
"Put the stash “{0}” back {1}.": "Put the stash “{0}” back {1}.",
"Put the {0} dropped commits back": "Put the {0} dropped commits back",
Expand Down Expand Up @@ -2688,6 +2701,7 @@
"Reopened PR #{0}.": "Reopened PR #{0}.",
"Reopened issue #{0}.": "Reopened issue #{0}.",
"Reopening…": "Reopening…",
"Reordering": "Reordering",
"Reordering stops at a merge — moving a commit past one would flatten it.": "Reordering stops at a merge — moving a commit past one would flatten it.",
"Replace and stage": "Replace and stage",
"Replace the file with {0} and stage it": "Replace the file with {0} and stage it",
Expand Down Expand Up @@ -3210,6 +3224,8 @@
"That URL uses an unsupported transport.": "That URL uses an unsupported transport.",
"That change is no longer there — the file moved underneath. Refresh and try again.": "That change is no longer there — the file moved underneath. Refresh and try again.",
"That commit is too far down the branch to drop from here. Start an interactive rebase from it instead.": "That commit is too far down the branch to drop from here. Start an interactive rebase from it instead.",
"That commit is too far down the branch to edit from here. Start an interactive rebase instead.": "That commit is too far down the branch to edit from here. Start an interactive rebase instead.",
"That commit isn't on the current branch, so its message can't be edited from here.": "That commit isn't on the current branch, so its message can't be edited from here.",
"That commit isn't on the current branch, so there's nothing to drop it from.": "That commit isn't on the current branch, so there's nothing to drop it from.",
"That didn't work.": "That didn't work.",
"That doesn't look like a valid repository URL.": "That doesn't look like a valid repository URL.",
Expand Down Expand Up @@ -3240,6 +3256,7 @@
"That worktree": "That worktree",
"That {0} is not here": "That {0} is not here",
"That's a merge commit — dropping it would flatten the history it joined. Revert it instead.": "That's a merge commit — dropping it would flatten the history it joined. Revert it instead.",
"That's a merge commit — editing its message would flatten the history it joined. Use an interactive rebase that keeps merges instead.": "That's a merge commit — editing its message would flatten the history it joined. Use an interactive rebase that keeps merges instead.",
"That's the clone folder itself, not a repository inside it.": "That's the clone folder itself, not a repository inside it.",
"That's the only commit on the branch — dropping it would leave nothing.": "That's the only commit on the branch — dropping it would leave nothing.",
"The 50 most recent commits. Clone the repository to read its whole history.": "The 50 most recent commits. Clone the repository to read its whole history.",
Expand Down Expand Up @@ -3284,6 +3301,7 @@
"The commit is": "The commit is",
"The commit is added to {0} as one new commit.": "The commit is added to {0} as one new commit.",
"The commit it holds": "The commit it holds",
"The commit needs a message.": "The commit needs a message.",
"The conflict is back, but {0} couldn't be restored on disk.": "The conflict is back, but {0} couldn't be restored on disk.",
"The conflict state came back empty.": "The conflict state came back empty.",
"The conflicts list: every conflicted file of this {0}, and {1}{2}": "The conflicts list: every conflicted file of this {0}, and {1}{2}",
Expand Down Expand Up @@ -3494,7 +3512,8 @@
"This workflow run couldn't be loaded.": "This workflow run couldn't be loaded.",
"Those are all the commits on the branch — dropping them would leave nothing.": "Those are all the commits on the branch — dropping them would leave nothing.",
"Those changes are no longer there — refresh and try again.": "Those changes are no longer there — refresh and try again.",
"Those commits are too far down the branch to {0} from here. Start an interactive rebase instead.": "Those commits are too far down the branch to {0} from here. Start an interactive rebase instead.",
"Those commits are too far down the branch to drop from here. Start an interactive rebase instead.": "Those commits are too far down the branch to drop from here. Start an interactive rebase instead.",
"Those commits are too far down the branch to squash from here. Start an interactive rebase instead.": "Those commits are too far down the branch to squash from here. Start an interactive rebase instead.",
"Those commits could not be read any more — refresh the graph and try again.": "Those commits could not be read any more — refresh the graph and try again.",
"Thread": "Thread",
"Thread reopened.": "Thread reopened.",
Expand Down Expand Up @@ -3713,6 +3732,7 @@
"You have this one at {0}.": "You have this one at {0}.",
"You have uncommitted changes. Commit or stash them, then drop the commit.": "You have uncommitted changes. Commit or stash them, then drop the commit.",
"You have uncommitted changes. Commit or stash them, then drop the commits.": "You have uncommitted changes. Commit or stash them, then drop the commits.",
"You have uncommitted changes. Commit or stash them, then edit the message.": "You have uncommitted changes. Commit or stash them, then edit the message.",
"You have uncommitted changes. Commit or stash them, then squash the commits.": "You have uncommitted changes. Commit or stash them, then squash the commits.",
"You have uncommitted changes. Commit or stash them, then start the rebase.": "You have uncommitted changes. Commit or stash them, then start the rebase.",
"You have {0} {1} doesn't, and it has {2} you don't. Git needs to know how to combine them — this choice applies to this pull only.": "You have {0} {1} doesn't, and it has {2} you don't. Git needs to know how to combine them — this choice applies to this pull only.",
Expand Down Expand Up @@ -3918,6 +3938,7 @@
"e.g. sonnet": "e.g. sonnet",
"edit": "edit",
"edited": "edited",
"editing a commit message": "editing a commit message",
"either arrow takes it": "either arrow takes it",
"empty": "empty",
"error": "error",
Expand Down Expand Up @@ -4529,6 +4550,7 @@
"{0} of {1} ticked · untick the last for all": "{0} of {1} ticked · untick the last for all",
"{0} on this file": "{0} on this file",
"{0} on {1}": "{0} on {1}",
"{0} on {1} gets the message below.": "{0} on {1} gets the message below.",
"{0} on {1} not pulled yet.": "{0} on {1} not pulled yet.",
"{0} on {1}/{2} yet: {3} pushed first.": "{0} on {1}/{2} yet: {3} pushed first.",
"{0} on {1}{2}{3}": "{0} on {1}{2}{3}",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gitstudio-desktop",
"version": "2.4.0",
"version": "2.5.0",
"private": true,
"license": "Apache-2.0",
"description": "GitStudio \u2014 the native cross-platform desktop app (Electron). Reuses @gitstudio/engine, git-service, webview-ui, and host-bridge behind host-agnostic seams.",
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/rebaseBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@ export class RebaseBridge {
if (!ctx) {
return { ok: false, expected: true, reason: "no-repo", message: l10n.t("Open a repository first.") };
}
const verb = req?.verb === "squash" ? "squash" : "drop";
const verb = req?.verb === "squash" ? "squash" : req?.verb === "reword" ? "reword" : "drop";
const plan = await planMany(ctx.process, verb, selectedCommits(Array.isArray(req?.shas) ? req.shas : []));
if (!plan.ok) {
return { ok: false, expected: true, reason: plan.reason, message: plan.message };
Expand Down Expand Up @@ -719,7 +719,7 @@ export class RebaseBridge {
return { status: "failed", ok: false, expected: true, message: l10n.t("Open a repository first.") };
}
try {
const verb = req?.verb === "squash" ? "squash" : "drop";
const verb = req?.verb === "squash" ? "squash" : req?.verb === "reword" ? "reword" : "drop";
const out = await rewriteMany(
ctx.process,
verb,
Expand All @@ -744,7 +744,7 @@ export class RebaseBridge {
if (!ctx) {
return { ok: false, changed: false, expected: true, message: l10n.t("Open a repository first.") };
}
const what = ["drop", "squash", "cherry-pick", "revert"].includes(String(req?.what)) ? String(req.what) : "change";
const what = ["drop", "squash", "reword", "cherry-pick", "revert"].includes(String(req?.what)) ? String(req.what) : "change";
const r = await undoRewrite(
ctx.process,
{
Expand Down
16 changes: 12 additions & 4 deletions apps/desktop/src/renderer/contextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as l10n from "@vscode/l10n";
interface MenuItem {
label: string;
/** A commit:action verb — or "drop", which is its own flow (see DROP_ITEM). */
action: CommitActionRequest["action"] | "drop";
action: CommitActionRequest["action"] | "drop" | "reword";
/** For checkout-ref: the ref this item checks out, its kind, and its full
* name when the row knew it (see RowRef.fullName). */
ref?: { name: string; kind: "head" | "remote" | "tag"; fullName?: string };
Expand Down Expand Up @@ -55,15 +55,19 @@ export const ITEMS: MenuItem[] = [
*/
const DROP_ITEM: MenuItem = { label: l10n.t("Drop commit…"), action: "drop", danger: true };

/** "Edit message…" (issue #75): offered, like Drop, only where it can work. */
const REWORD_ITEM: MenuItem = { label: l10n.t("Edit message…"), action: "reword" };

/**
* The rows for one commit: "Checkout <ref>" for the refs on it, then the
* commit actions — with "Drop commit…" beside Revert and the resets when the
* commit can be dropped from the current branch.
*/
export function commitMenuRows(refs: readonly RowRef[], opts: { drop?: boolean } = {}): MenuItem[] {
export function commitMenuRows(refs: readonly RowRef[], opts: { drop?: boolean; reword?: boolean } = {}): MenuItem[] {
const rows: MenuItem[] = refMenuItems(refs).map(refRow);
for (const item of ITEMS) {
rows.push(item);
if (item.action === "revert" && opts.reword) rows.push(REWORD_ITEM);
if (item.action === "revert" && opts.drop) rows.push(DROP_ITEM);
}
return rows;
Expand Down Expand Up @@ -108,7 +112,7 @@ export function commitActionItem(action: string): MenuItem | undefined {
// ── Several commits at once (issue #32) ──────────────────────────────────────

/** An action of the menu for a selection of several commits. */
export type ManyAction = "cherry-pick-many" | "revert-many" | "squash-many" | "drop-many" | "compare-two" | "copy-shas";
export type ManyAction = "cherry-pick-many" | "revert-many" | "squash-many" | "drop-many" | "compare-two" | "copy-shas" | "reword";

/** One row of that menu (and one button of the "N commits selected" summary). */
export interface ManyMenuRow {
Expand Down Expand Up @@ -251,7 +255,7 @@ export class CommitContextMenu {
x: number,
y: number,
refs: readonly RowRef[] = [],
opts: { drop?: boolean } = {},
opts: { drop?: boolean; reword?: boolean } = {},
): void {
this.close();
this.layer = registerLayer(() => this.close(false), "menu");
Expand Down Expand Up @@ -350,6 +354,10 @@ export class CommitContextMenu {
this.drop?.(sha);
return;
}
if (item.action === "reword") {
this.many?.("reword", [sha]);
return;
}
let name: string | undefined;
// Only awaited when the table says there is something to ask. An
// unconditional await defers even a question-less item by a microtask, so
Expand Down
84 changes: 83 additions & 1 deletion apps/desktop/src/renderer/multiCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@
// Everything it touches comes in through `ManyDeps`, so the flow is tested
// without a DOM or an Electron (test/multiCommitFlow.test.ts).

import { applyManyMessage, dropManyQuestion, manyOutcomeMessage, squashCarryQuestion, squashQuestion } from "@gitstudio/engine/rebase/many";
import {
applyManyMessage,
dropManyQuestion,
manyOutcomeMessage,
rewordOutcomeMessage,
rewordQuestion,
squashCarryQuestion,
squashQuestion,
} from "@gitstudio/engine/rebase/many";
import type {
CommitActionRequest,
CommitActionResult,
Expand Down Expand Up @@ -74,6 +82,8 @@ export async function runManyAction(action: ManyAction, shas: string[], d: ManyD
return rewriteManyFlow("drop", shas, d);
case "squash-many":
return rewriteManyFlow("squash", shas, d);
case "reword":
return rewordFlow(shas, d);
case "compare-two":
if (shas.length !== 2) return "refused";
// Newest first as listed: the second is the older — the base.
Expand Down Expand Up @@ -248,3 +258,75 @@ async function rewriteManyFlow(verb: "drop" | "squash", shas: string[], d: ManyD
d.toast(text, out.expected ? "info" : "error");
return "failed";
}

/**
* Edit message… (issue #75): one commit's whole message in the editor, then a
* rebase that rewords it and replays what came after — git-service's
* multiCommit.ts, the extension's path too — with Undo after.
*/
async function rewordFlow(shas: string[], d: ManyDeps): Promise<ManyFlowResult> {
const plan = await d.plan({ verb: "reword", shas, preflight: true });
if (!plan.ok) {
d.toast(plan.message, "info");
return "refused";
}
if (plan.blocked) {
d.toast(plan.blocked, "info");
return "refused";
}
const q = rewordQuestion(plan);
const m = await d.message({ title: q.title, hint: q.message, value: plan.message ?? "", okLabel: l10n.t("Edit message") });
const message = m?.trim();
if (!message || message === (plan.message ?? "").trim()) return "cancelled";

let carry = false;
if (plan.carryable.length > 0) {
const picked = await d.choose({
title: l10n.t("Move the branches on rewritten commits too?"),
hint: q.message,
cancelId: "cancel",
choices: [
{ id: "carry", label: l10n.t("Edit and move those branches"), sub: l10n.t("They follow onto the rewritten commits."), icon: "git-branch" },
{ id: "only", label: l10n.t("Edit on this branch only"), sub: l10n.t("They keep pointing at the commits as they are now."), icon: "git-commit" },
],
});
if (picked !== "carry" && picked !== "only") return "cancelled";
carry = picked === "carry";
}

const out = await d.rewrite({ verb: "reword", shas: plan.shas, head: plan.head, carry, message });
const text = rewordOutcomeMessage(out);
if (out.status === "done") {
const { before, after, carried } = out;
if (before && after) {
d.undoable(text, {
label: l10n.t("Put the old message back"),
undo: async () => {
const back = await d.undo({
before,
after,
what: "reword",
...(out.branch !== undefined ? { branch: out.branch } : {}),
...(carried?.length ? { carried } : {}),
});
if (back.ok) return undefined;
const why = back.message ?? l10n.t("Couldn't put the branch back.");
return back.expected ? { info: why } : why;
},
after: () => d.refresh(),
});
} else {
d.toast(text, "success");
}
await d.refresh();
return "done";
}
if (out.status === "stopped") {
d.toast(text, "info");
await d.landOnConflicts();
return "stopped";
}
d.toast(text, out.expected ? "info" : "error");
return "failed";
}

Loading
Loading