Skip to content

Commit 63e3d19

Browse files
committed
feat: add HEAD reset actions
1 parent 2c98104 commit 63e3d19

9 files changed

Lines changed: 135 additions & 5 deletions

File tree

‎src-tauri/src/git/cli.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4331,6 +4331,7 @@ impl GitOperationHandler for CliGitHandler {
43314331
let mode_flag = match request.mode {
43324332
ResetMode::Soft => "--soft",
43334333
ResetMode::Mixed => "--mixed",
4334+
ResetMode::Hard => "--hard",
43344335
};
43354336
Self::run_git(
43364337
&["reset", mode_flag, request.target.trim()],
@@ -4339,6 +4340,7 @@ impl GitOperationHandler for CliGitHandler {
43394340
let mode_label = match request.mode {
43404341
ResetMode::Soft => "soft",
43414342
ResetMode::Mixed => "mixed",
4343+
ResetMode::Hard => "hard",
43424344
};
43434345
Ok(OperationResult {
43444346
message: format!("Reset ({mode_label}) to {}", request.target.trim()),
@@ -5274,6 +5276,15 @@ mod tests {
52745276
);
52755277
}
52765278

5279+
#[test]
5280+
fn reset_mode_hard_serialises_as_hard() {
5281+
assert_eq!(serde_json::to_string(&ResetMode::Hard).unwrap(), "\"hard\"");
5282+
assert_eq!(
5283+
serde_json::from_str::<ResetMode>("\"hard\"").unwrap(),
5284+
ResetMode::Hard
5285+
);
5286+
}
5287+
52775288
#[test]
52785289
fn status_line_modified_unstaged() {
52795290
let result = CliGitHandler::parse_status_line(" M src/main.rs");

‎src-tauri/src/git/types.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,7 @@ pub struct StashPushRequest {
11021102
pub enum ResetMode {
11031103
Soft,
11041104
Mixed,
1105+
Hard,
11051106
}
11061107

11071108
#[derive(Debug, Clone, Deserialize)]

‎src-tauri/tests/git.rs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use gitmun_lib::git::handler::GitOperationHandler;
1010
use gitmun_lib::git::types::{
1111
CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, CommitRequest, CreateBranchRequest,
1212
ExportPatchFileSelection, ExportPatchRequest, ExportPatchScope, FileRequest,
13-
ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, SetBranchUpstreamRequest,
14-
StageFilesRequest, SubmoduleActionRequest, SubmoduleState,
13+
ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, ResetMode, ResetRequest,
14+
SetBranchUpstreamRequest, StageFilesRequest, SubmoduleActionRequest, SubmoduleState,
1515
};
1616

1717
fn init_repo() -> TempDir {
@@ -602,6 +602,33 @@ fn unstage_file_in_repo_with_head_keeps_change_unstaged() {
602602
);
603603
}
604604

605+
#[test]
606+
fn hard_reset_to_head_discards_tracked_changes_and_keeps_untracked_files() {
607+
let dir = init_repo();
608+
write_file(dir.path(), "tracked.txt", "v1");
609+
git(dir.path(), &["add", "tracked.txt"]);
610+
git(dir.path(), &["commit", "-m", "add tracked file"]);
611+
write_file(dir.path(), "tracked.txt", "v2");
612+
git(dir.path(), &["add", "tracked.txt"]);
613+
write_file(dir.path(), "untracked.txt", "new");
614+
615+
let result = handler()
616+
.reset(&ResetRequest {
617+
repo_path: dir.path().to_str().unwrap().to_string(),
618+
target: "HEAD".to_string(),
619+
mode: ResetMode::Hard,
620+
})
621+
.expect("hard reset");
622+
623+
assert_eq!(result.message, "Reset (hard) to HEAD");
624+
assert_eq!(read_file(dir.path(), "tracked.txt"), "v1");
625+
assert_eq!(read_file(dir.path(), "untracked.txt"), "new");
626+
assert_eq!(
627+
git_stdout(dir.path(), &["status", "--porcelain"]),
628+
"?? untracked.txt"
629+
);
630+
}
631+
605632
#[test]
606633
fn status_detects_clean_submodule() {
607634
let (parent, _submodule) = repo_with_submodule();

‎src/api/commands.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,9 @@ export function revertAbort(repoPath: string): Promise<OperationResult> {
342342
return invoke<OperationResult>("revert_abort", {request: {repoPath}});
343343
}
344344

345-
export function resetTo(repoPath: string, target: string, mode: "soft" | "mixed"): Promise<OperationResult> {
345+
export type ResetMode = "soft" | "mixed" | "hard";
346+
347+
export function resetTo(repoPath: string, target: string, mode: ResetMode): Promise<OperationResult> {
346348
return invoke<OperationResult>("reset", {request: {repoPath, target, mode}});
347349
}
348350

‎src/components/ProjectView.tsx‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useGitTags } from "../hooks/useGitTags";
4040
import { useGitRemotes } from "../hooks/useGitRemotes";
4141
import { useGitStashes } from "../hooks/useGitStashes";
4242
import * as api from "../api/commands";
43+
import type { ResetMode } from "../api/commands";
4344
import type {
4445
CommitLogScope,
4546
CommitMarkers,
@@ -1875,6 +1876,29 @@ export function ProjectView({
18751876
}
18761877
}, [repoPath, refreshAll, showToast, t]);
18771878

1879+
const handleResetHead = useCallback(async (mode: Extract<ResetMode, "mixed" | "hard">) => {
1880+
if (!repoPath) return;
1881+
const confirmed = await ask(
1882+
mode === "hard" ? t("ask.resetHead.hardMessage") : t("ask.resetHead.mixedMessage"),
1883+
{
1884+
title: mode === "hard" ? t("ask.resetHead.hardTitle") : t("ask.resetHead.mixedTitle"),
1885+
kind: "warning",
1886+
okLabel: t("actions.reset"),
1887+
cancelLabel: t("actions.cancel"),
1888+
},
1889+
);
1890+
if (!confirmed) return;
1891+
try {
1892+
const result = await api.resetTo(repoPath, "HEAD", mode);
1893+
showToast(result.message, "success");
1894+
appendResultLog("success", result.message, result.backendUsed);
1895+
await refreshAll();
1896+
} catch (e) {
1897+
showToast(String(e), "error");
1898+
appendResultLog("error", t("log.resetFailed", { message: String(e) }), "unknown");
1899+
}
1900+
}, [repoPath, refreshAll, showToast, t]);
1901+
18781902
const handleMergeConfirm = useCallback(async (strategy: MergeStrategy) => {
18791903
if (!repoPath || !mergePendingBranch) return;
18801904
setMergePendingBranch(null);
@@ -2153,6 +2177,7 @@ export function ProjectView({
21532177
pushDisabled={remoteActionState.disabled}
21542178
pushTitle={remoteActionTitle}
21552179
onStash={handleStash}
2180+
onReset={handleResetHead}
21562181
onImportPatch={handleImportPatch}
21572182
onExportPatch={handleExportPatch}
21582183
selectedPatchExportEnabled={selectedPatchFiles.length > 0}

‎src/components/Titlebar.test.tsx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function renderTitlebar(
4040
onImportPatch?: () => void;
4141
onExportPatch?: (scope: "staged" | "unstaged" | "all" | "selected") => void;
4242
selectedPatchExportEnabled?: boolean;
43+
onReset?: (mode: "mixed" | "hard") => void;
4344
} = {},
4445
) {
4546
const onImportPatch = patchHandlers.onImportPatch ?? vi.fn();
@@ -70,6 +71,7 @@ function renderTitlebar(
7071
onPush={vi.fn()}
7172
pushLabel={pushLabel}
7273
onStash={vi.fn()}
74+
onReset={patchHandlers.onReset ?? vi.fn()}
7375
onImportPatch={onImportPatch}
7476
onExportPatch={onExportPatch}
7577
selectedPatchExportEnabled={patchHandlers.selectedPatchExportEnabled ?? false}
@@ -190,6 +192,46 @@ describe("Titlebar", () => {
190192
expect(screen.getByText("Export all changes patch...")).toBeInTheDocument();
191193
});
192194

195+
it("shows reset actions when a repository is open", () => {
196+
renderTitlebar([makeBranch()]);
197+
198+
fireEvent.click(screen.getByText("More"));
199+
200+
expect(screen.getByText("Reset")).toBeInTheDocument();
201+
expect(screen.getByText("Unstage all changes...")).toBeInTheDocument();
202+
expect(screen.getByText("Discard tracked changes...")).toBeInTheDocument();
203+
});
204+
205+
it("calls reset with mixed mode from the more menu", () => {
206+
const onReset = vi.fn();
207+
renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { onReset });
208+
209+
fireEvent.click(screen.getByText("More"));
210+
fireEvent.click(screen.getByText("Unstage all changes..."));
211+
212+
expect(onReset).toHaveBeenCalledWith("mixed");
213+
});
214+
215+
it("calls reset with hard mode from the more menu", () => {
216+
const onReset = vi.fn();
217+
renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { onReset });
218+
219+
fireEvent.click(screen.getByText("More"));
220+
fireEvent.click(screen.getByText("Discard tracked changes..."));
221+
222+
expect(onReset).toHaveBeenCalledWith("hard");
223+
});
224+
225+
it("does not show reset actions when no repository is open", () => {
226+
renderTitlebar([], "Push", null);
227+
228+
fireEvent.click(screen.getByText("More"));
229+
230+
expect(screen.queryByText("Reset")).not.toBeInTheDocument();
231+
expect(screen.queryByText("Unstage all changes...")).not.toBeInTheDocument();
232+
expect(screen.queryByText("Discard tracked changes...")).not.toBeInTheDocument();
233+
});
234+
193235
it("disables selected patch export until files are checked", () => {
194236
renderTitlebar([makeBranch()]);
195237

‎src/components/Titlebar.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
MoreIcon,
77
} from "./icons";
88
import * as api from "../api/commands";
9+
import type { ResetMode } from "../api/commands";
910
import type { PlatformType } from "../hooks/usePlatform";
1011
import type { BranchInfo, RepoOpenLocation, RepoOpenLocationKind } from "../types";
1112
import "./Titlebar.css";
@@ -38,6 +39,7 @@ type TitlebarProps = {
3839
pushDisabled?: boolean;
3940
pushTitle?: string;
4041
onStash: () => void;
42+
onReset: (mode: Extract<ResetMode, "mixed" | "hard">) => void;
4143
onImportPatch: () => void;
4244
onExportPatch: (scope: "staged" | "unstaged" | "all" | "selected") => void;
4345
selectedPatchExportEnabled: boolean;
@@ -63,7 +65,7 @@ export function Titlebar({
6365
identityInitials, identityAvatarUrl, recentRepos, searchQuery, searchInputRef,
6466
onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick,
6567
onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash,
66-
onImportPatch, onExportPatch, selectedPatchExportEnabled,
68+
onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled,
6769
identityOpen, remoteOp,
6870
}: TitlebarProps) {
6971
const { t } = useTranslation("titlebar");
@@ -159,6 +161,7 @@ export function Titlebar({
159161
<ActionBtn icon={<StashIcon size={18} className="titlebar__toolbar-icon" />} label={t("actions.stash")} onClick={onStash} disabled={!repoPath} />
160162
<MoreDropdown
161163
repoPath={repoPath}
164+
onReset={onReset}
162165
onImportPatch={onImportPatch}
163166
onExportPatch={onExportPatch}
164167
selectedPatchExportEnabled={selectedPatchExportEnabled}
@@ -231,8 +234,9 @@ export function Titlebar({
231234
);
232235
}
233236

234-
function MoreDropdown({ repoPath, onImportPatch, onExportPatch, selectedPatchExportEnabled }: {
237+
function MoreDropdown({ repoPath, onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled }: {
235238
repoPath: string | null;
239+
onReset: (mode: Extract<ResetMode, "mixed" | "hard">) => void;
236240
onImportPatch: () => void;
237241
onExportPatch: (scope: "staged" | "unstaged" | "all" | "selected") => void;
238242
selectedPatchExportEnabled: boolean;
@@ -304,6 +308,13 @@ function MoreDropdown({ repoPath, onImportPatch, onExportPatch, selectedPatchExp
304308
</div>
305309
</div>
306310
</div>
311+
<div className="titlebar__open-menu-heading">{t("reset.heading")}</div>
312+
<div className="titlebar__open-menu-item" onClick={() => run(() => onReset("mixed"))}>
313+
<span>{t("reset.mixed")}</span>
314+
</div>
315+
<div className="titlebar__open-menu-item" onClick={() => run(() => onReset("hard"))}>
316+
<span>{t("reset.hard")}</span>
317+
</div>
307318
</div>
308319
)}
309320
</div>

‎src/i18n/locales/en/projectView.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@
8080
"softMode": "Soft",
8181
"title": "{{mode}} Reset"
8282
},
83+
"resetHead": {
84+
"hardMessage": "Discard all tracked working tree changes and staged changes? Untracked files will be kept. This cannot be undone from Gitmun.",
85+
"hardTitle": "Discard Tracked Changes",
86+
"mixedMessage": "Unstage all staged changes? Working tree files will be kept.",
87+
"mixedTitle": "Unstage All Changes"
88+
},
8389
"startRebase": {
8490
"message": "Rebase \"{{branch}}\" onto \"{{onto}}\"?",
8591
"title": "Start Rebase"

‎src/i18n/locales/en/titlebar.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,10 @@
3232
"exportUnstaged": "Export unstaged patch...",
3333
"heading": "Patch files",
3434
"import": "Import patch..."
35+
},
36+
"reset": {
37+
"hard": "Discard tracked changes...",
38+
"heading": "Reset",
39+
"mixed": "Unstage all changes..."
3540
}
3641
}

0 commit comments

Comments
 (0)