From 08869c0f33d1aac6c38666eac5df8801f28d9010 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:33:46 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(git):=20Reset/Reflog=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E5=AE=9A=E7=BE=A9=E3=83=BB=E3=83=88=E3=83=AC=E3=82=A4=E3=83=88?= =?UTF-8?q?=E3=83=BBgit2=E3=83=90=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3?= =?UTF-8?q?=E3=83=89=E5=AE=9F=E8=A3=85=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResetMode/ResetResult/ReflogEntry型、GitBackendトレイトに reset/reset_file/get_reflogメソッドを追加し、git2-rsで実装。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/git/backend.rs | 11 +++- src-tauri/src/git/error.rs | 6 ++ src-tauri/src/git/git2_backend.rs | 94 ++++++++++++++++++++++++++++++- src-tauri/src/git/types.rs | 29 ++++++++++ 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/git/backend.rs b/src-tauri/src/git/backend.rs index ec77be8..d5e87dc 100644 --- a/src-tauri/src/git/backend.rs +++ b/src-tauri/src/git/backend.rs @@ -5,8 +5,8 @@ use crate::git::types::{ BlameResult, BranchInfo, CherryPickMode, CherryPickResult, CommitDetail, CommitInfo, CommitLogResult, CommitResult, ConflictFile, ConflictResolution, DiffOptions, FetchResult, FileDiff, HunkIdentifier, LineRange, LogFilter, MergeBaseContent, MergeOption, MergeResult, - PullOption, PushResult, RebaseResult, RebaseState, RebaseTodoEntry, RemoteInfo, RepoStatus, - RevertMode, RevertResult, StashEntry, TagInfo, + PullOption, PushResult, RebaseResult, RebaseState, RebaseTodoEntry, ReflogEntry, RemoteInfo, + RepoStatus, ResetMode, ResetResult, RevertMode, RevertResult, StashEntry, TagInfo, }; pub trait GitBackend: Send + Sync { @@ -101,4 +101,11 @@ pub trait GitBackend: Send + Sync { fn is_reverting(&self) -> GitResult; fn abort_revert(&self) -> GitResult<()>; fn continue_revert(&self) -> GitResult; + + // Reset operations + fn reset(&self, oid: &str, mode: ResetMode) -> GitResult; + fn reset_file(&self, path: &str, oid: &str) -> GitResult<()>; + + // Reflog operations + fn get_reflog(&self, ref_name: &str, limit: usize) -> GitResult>; } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index bb5d116..56de440 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -97,6 +97,12 @@ pub enum GitError { #[error("failed to revert: {0}")] RevertFailed(#[source] Box), + + #[error("failed to reset: {0}")] + ResetFailed(#[source] Box), + + #[error("failed to read reflog: {0}")] + ReflogFailed(#[source] Box), } pub type GitResult = Result; diff --git a/src-tauri/src/git/git2_backend.rs b/src-tauri/src/git/git2_backend.rs index d0984a9..66d2f04 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -15,8 +15,8 @@ use crate::git::types::{ DiffHunk, DiffLine, DiffLineKind, DiffOptions, FetchResult, FileDiff, FileStatus, FileStatusKind, GraphEdge, GraphNodeType, HunkIdentifier, LineRange, LogFilter, MergeBaseContent, MergeKind, MergeOption, MergeResult, PullOption, PushResult, RebaseAction, - RebaseResult, RebaseState, RebaseTodoEntry, RemoteInfo, RepoStatus, RevertMode, RevertResult, - StagingState, StashEntry, TagInfo, WordSegment, + RebaseResult, RebaseState, RebaseTodoEntry, ReflogEntry, RemoteInfo, RepoStatus, ResetMode, + ResetResult, RevertMode, RevertResult, StagingState, StashEntry, TagInfo, WordSegment, }; pub struct Git2Backend { @@ -1950,6 +1950,84 @@ impl GitBackend for Git2Backend { oid: Some(oid.to_string()), }) } + + fn reset(&self, oid_str: &str, mode: ResetMode) -> GitResult { + let repo = self.repo.lock().unwrap(); + + let oid = Oid::from_str(oid_str).map_err(|e| GitError::ResetFailed(Box::new(e)))?; + let commit = repo + .find_commit(oid) + .map_err(|e| GitError::ResetFailed(Box::new(e)))?; + + let reset_type = match mode { + ResetMode::Soft => git2::ResetType::Soft, + ResetMode::Mixed => git2::ResetType::Mixed, + ResetMode::Hard => git2::ResetType::Hard, + }; + + repo.reset(commit.as_object(), reset_type, None) + .map_err(|e| GitError::ResetFailed(Box::new(e)))?; + + Ok(ResetResult { + oid: oid_str.to_string(), + }) + } + + fn reset_file(&self, path: &str, oid_str: &str) -> GitResult<()> { + let repo = self.repo.lock().unwrap(); + + let oid = Oid::from_str(oid_str).map_err(|e| GitError::ResetFailed(Box::new(e)))?; + let commit = repo + .find_commit(oid) + .map_err(|e| GitError::ResetFailed(Box::new(e)))?; + + repo.reset_default(Some(commit.as_object()), [path]) + .map_err(|e| GitError::ResetFailed(Box::new(e)))?; + + Ok(()) + } + + fn get_reflog(&self, ref_name: &str, limit: usize) -> GitResult> { + let repo = self.repo.lock().unwrap(); + + let reflog = repo + .reflog(ref_name) + .map_err(|e| GitError::ReflogFailed(Box::new(e)))?; + + let mut entries = Vec::new(); + for i in 0..reflog.len() { + if entries.len() >= limit { + break; + } + let entry = reflog.get(i).ok_or_else(|| { + GitError::ReflogFailed(format!("reflog entry {i} not found").into()) + })?; + + let old_oid = entry.id_old().to_string(); + let new_oid = entry.id_new().to_string(); + let new_short_oid = new_oid[..7.min(new_oid.len())].to_string(); + + let raw_message = entry.message().unwrap_or("").to_string(); + let (action, message) = parse_reflog_message(&raw_message); + + let committer = entry.committer(); + let committer_name = committer.name().unwrap_or("").to_string(); + let committer_date = committer.when().seconds(); + + entries.push(ReflogEntry { + index: i, + old_oid, + new_oid, + new_short_oid, + action, + message, + committer_name, + committer_date, + }); + } + + Ok(entries) + } } impl Git2Backend { @@ -2667,6 +2745,18 @@ fn compute_word_diff_pair( (del_segments, add_segments) } +/// Parse reflog message into action and description. +/// Format: "action: description" (e.g. "commit: initial commit", "checkout: moving from main to feature") +fn parse_reflog_message(message: &str) -> (String, String) { + match message.find(": ") { + Some(pos) => ( + message[..pos].to_string(), + message[pos + 2..].to_string(), + ), + None => (message.to_string(), String::new()), + } +} + /// Parse branch name from git stash message. /// Format: "WIP on : ..." or "On : " fn parse_stash_branch_name(message: &str) -> String { diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index e8bcc65..e190edd 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -393,6 +393,35 @@ pub struct CherryPickResult { pub oid: Option, } +// === Reset types === + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResetMode { + Soft, + Mixed, + Hard, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetResult { + pub oid: String, +} + +// === Reflog types === + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReflogEntry { + pub index: usize, + pub old_oid: String, + pub new_oid: String, + pub new_short_oid: String, + pub action: String, + pub message: String, + pub committer_name: String, + pub committer_date: i64, +} + // === Revert types === #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] From 7e6c75db2084eff256aa0a27710423bbbc233427 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:33:51 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(commands):=20Reset/Reflog=E3=81=AETaur?= =?UTF-8?q?i=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reset, reset_file, get_reflogコマンドを登録。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/reset.rs | 48 +++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 3 +++ 3 files changed, 52 insertions(+) create mode 100644 src-tauri/src/commands/reset.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5315a54..ee4e1c1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod history; pub mod hosting; pub mod rebase; pub mod remote; +pub mod reset; pub mod revert; pub mod stash; pub mod tag; diff --git a/src-tauri/src/commands/reset.rs b/src-tauri/src/commands/reset.rs new file mode 100644 index 0000000..16d8eff --- /dev/null +++ b/src-tauri/src/commands/reset.rs @@ -0,0 +1,48 @@ +use tauri::State; + +use crate::git::types::{ReflogEntry, ResetMode, ResetResult}; +use crate::state::AppState; + +#[tauri::command] +pub fn reset( + oid: String, + mode: ResetMode, + state: State<'_, AppState>, +) -> Result { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.reset(&oid, mode).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn reset_file( + path: String, + oid: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.reset_file(&path, &oid).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_reflog( + ref_name: String, + limit: usize, + state: State<'_, AppState>, +) -> Result, String> { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend + .get_reflog(&ref_name, limit) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc07887..7d1958b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -153,6 +153,9 @@ pub fn run() { commands::revert::is_reverting, commands::revert::abort_revert, commands::revert::continue_revert, + commands::reset::reset, + commands::reset::reset_file, + commands::reset::get_reflog, commands::ai::detect_cli_adapters, commands::ai::generate_commit_message, commands::ai::review_diff, From cd8e160694807e4b8fe9131e17197176f1bd88cd Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:33:55 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test(git):=20Reset/Reflog=E3=81=AE=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E7=B5=B1=E5=90=88?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft/Mixed/Hard Reset、ファイル単位Reset、Reflog取得の7テストを追加。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/tests/git2_backend_test.rs | 179 ++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/git2_backend_test.rs b/src-tauri/tests/git2_backend_test.rs index 10f501a..2ebcb03 100644 --- a/src-tauri/tests/git2_backend_test.rs +++ b/src-tauri/tests/git2_backend_test.rs @@ -6,7 +6,7 @@ use app_lib::git::backend::GitBackend; use app_lib::git::git2_backend::Git2Backend; use app_lib::git::types::{ CherryPickMode, ConflictResolution, DiffLineKind, DiffOptions, HunkIdentifier, LineRange, - LogFilter, MergeOption, PullOption, RevertMode, + LogFilter, MergeOption, PullOption, ResetMode, RevertMode, }; fn init_test_repo(dir: &Path) { @@ -1939,3 +1939,180 @@ fn continue_revert_after_conflict_resolution() { let content = fs::read_to_string(tmp.path().join("revert_cont.txt")).unwrap(); assert_eq!(content, "resolved content\n"); } + +// === Reset tests === + +#[test] +fn reset_soft_moves_head_preserves_staging() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + // Create a second commit + fs::write(tmp.path().join("second.txt"), "second").unwrap(); + backend.stage(Path::new("second.txt")).unwrap(); + backend.commit("second commit", false).unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 2, 0).unwrap(); + let first_oid = log.commits[1].oid.clone(); + + let result = backend.reset(&first_oid, ResetMode::Soft).unwrap(); + assert_eq!(result.oid, first_oid); + + // second.txt should still exist in working tree + assert!(tmp.path().join("second.txt").exists()); + + // second.txt should be staged (soft reset keeps index) + let status = backend.status().unwrap(); + let staged_files: Vec<_> = status + .files + .iter() + .filter(|f| f.staging == app_lib::git::types::StagingState::Staged) + .collect(); + assert!(!staged_files.is_empty(), "Soft reset should keep changes staged"); +} + +#[test] +fn reset_mixed_moves_head_unstages_changes() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + fs::write(tmp.path().join("mixed.txt"), "mixed content").unwrap(); + backend.stage(Path::new("mixed.txt")).unwrap(); + backend.commit("mixed commit", false).unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 2, 0).unwrap(); + let first_oid = log.commits[1].oid.clone(); + + let result = backend.reset(&first_oid, ResetMode::Mixed).unwrap(); + assert_eq!(result.oid, first_oid); + + // File should still exist in working tree + assert!(tmp.path().join("mixed.txt").exists()); + + // File should be unstaged + let status = backend.status().unwrap(); + let unstaged: Vec<_> = status + .files + .iter() + .filter(|f| f.path == "mixed.txt" && f.staging == app_lib::git::types::StagingState::Unstaged) + .collect(); + assert!(!unstaged.is_empty(), "Mixed reset should unstage changes"); +} + +#[test] +fn reset_hard_discards_all_changes() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + fs::write(tmp.path().join("hard.txt"), "hard content").unwrap(); + backend.stage(Path::new("hard.txt")).unwrap(); + backend.commit("hard commit", false).unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 2, 0).unwrap(); + let first_oid = log.commits[1].oid.clone(); + + let result = backend.reset(&first_oid, ResetMode::Hard).unwrap(); + assert_eq!(result.oid, first_oid); + + // File should be removed from working tree + assert!(!tmp.path().join("hard.txt").exists()); +} + +#[test] +fn reset_file_restores_file_in_index() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + // Create and commit a file + fs::write(tmp.path().join("resetfile.txt"), "original\n").unwrap(); + backend.stage(Path::new("resetfile.txt")).unwrap(); + backend.commit("add resetfile", false).unwrap(); + + let log_filter = LogFilter { + author: None, + since: None, + until: None, + message: None, + path: None, + }; + let log = backend.get_commit_log(&log_filter, 1, 0).unwrap(); + let commit_oid = log.commits[0].oid.clone(); + + // Modify and stage again + fs::write(tmp.path().join("resetfile.txt"), "modified\n").unwrap(); + backend.stage(Path::new("resetfile.txt")).unwrap(); + + // Reset file to original commit + backend.reset_file("resetfile.txt", &commit_oid).unwrap(); + + // After reset_file, the index should be reverted but working tree should still have modified content + let status = backend.status().unwrap(); + // The file should show as modified in working tree (unstaged) + let has_changes = status.files.iter().any(|f| f.path == "resetfile.txt"); + assert!(has_changes, "File should show changes after reset_file"); +} + +// === Reflog tests === + +#[test] +fn get_reflog_returns_entries() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + let entries = backend.get_reflog("HEAD", 10).unwrap(); + assert!(!entries.is_empty(), "Reflog should have at least one entry"); + assert_eq!(entries[0].index, 0); + assert!(!entries[0].new_oid.is_empty()); + assert!(!entries[0].new_short_oid.is_empty()); +} + +#[test] +fn get_reflog_respects_limit() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + // Create additional commits to have multiple reflog entries + fs::write(tmp.path().join("a.txt"), "a").unwrap(); + backend.stage(Path::new("a.txt")).unwrap(); + backend.commit("commit a", false).unwrap(); + + fs::write(tmp.path().join("b.txt"), "b").unwrap(); + backend.stage(Path::new("b.txt")).unwrap(); + backend.commit("commit b", false).unwrap(); + + // Limit to 2 entries + let entries = backend.get_reflog("HEAD", 2).unwrap(); + assert_eq!(entries.len(), 2); +} + +#[test] +fn get_reflog_has_action_and_message() { + let tmp = tempfile::tempdir().unwrap(); + let backend = init_repo_with_commit(tmp.path()); + + let entries = backend.get_reflog("HEAD", 10).unwrap(); + // The first commit's reflog entry should have "commit" as action + let has_commit_action = entries.iter().any(|e| e.action.contains("commit")); + assert!(has_commit_action, "Reflog should contain commit action entries"); +} From 3452738a56aaf48670251fa69301dd28f93a94bf Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:34:02 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(frontend):=20Reset/Reflog=E3=81=AEIPC?= =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9=E3=83=BB=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=A2=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reset.ts/reflog.tsサービス、gitStoreにresetToCommit/resetFileアクション、 uiStoreにreset/reflogページID追加。ストアテスト4件追加。 Co-Authored-By: Claude Opus 4.6 --- src/services/reflog.ts | 22 ++++++++++++ src/services/reset.ts | 18 ++++++++++ src/stores/__tests__/gitStore.test.ts | 50 +++++++++++++++++++++++++++ src/stores/gitStore.ts | 25 ++++++++++++++ src/stores/uiStore.ts | 2 ++ 5 files changed, 117 insertions(+) create mode 100644 src/services/reflog.ts create mode 100644 src/services/reset.ts diff --git a/src/services/reflog.ts b/src/services/reflog.ts new file mode 100644 index 0000000..f43f7be --- /dev/null +++ b/src/services/reflog.ts @@ -0,0 +1,22 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface ReflogEntry { + index: number; + old_oid: string; + new_oid: string; + new_short_oid: string; + action: string; + message: string; + committer_name: string; + committer_date: number; +} + +export function getReflog( + refName: string, + limit: number, +): Promise { + return invoke("get_reflog", { + refName, + limit, + }); +} diff --git a/src/services/reset.ts b/src/services/reset.ts new file mode 100644 index 0000000..8447043 --- /dev/null +++ b/src/services/reset.ts @@ -0,0 +1,18 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type ResetMode = "soft" | "mixed" | "hard"; + +export interface ResetResult { + oid: string; +} + +export function resetToCommit( + oid: string, + mode: ResetMode, +): Promise { + return invoke("reset", { oid, mode }); +} + +export function resetFile(path: string, oid: string): Promise { + return invoke("reset_file", { path, oid }); +} diff --git a/src/stores/__tests__/gitStore.test.ts b/src/stores/__tests__/gitStore.test.ts index dda7f5f..4df66ee 100644 --- a/src/stores/__tests__/gitStore.test.ts +++ b/src/stores/__tests__/gitStore.test.ts @@ -1397,6 +1397,56 @@ describe("gitStore", () => { }); }); + describe("resetToCommit", () => { + it("returns result on success", async () => { + const mockResult = { oid: "abc123" }; + mockedInvoke.mockResolvedValueOnce(mockResult); + + const result = await useGitStore + .getState() + .resetToCommit("abc123", "mixed"); + + expect(result).toEqual(mockResult); + expect(mockedInvoke).toHaveBeenCalledWith("reset", { + oid: "abc123", + mode: "mixed", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("reset error")); + + await expect( + useGitStore.getState().resetToCommit("abc", "soft"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("reset error"); + }); + }); + + describe("resetFile", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore.getState().resetFile("file.txt", "abc123"); + + expect(mockedInvoke).toHaveBeenCalledWith("reset_file", { + path: "file.txt", + oid: "abc123", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("reset file error")); + + await expect( + useGitStore.getState().resetFile("file.txt", "abc"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("reset file error"); + }); + }); + describe("clearError", () => { it("clears the error state", async () => { mockedInvoke.mockRejectedValueOnce(new Error("some error")); diff --git a/src/stores/gitStore.ts b/src/stores/gitStore.ts index c95f906..2e51e60 100644 --- a/src/stores/gitStore.ts +++ b/src/stores/gitStore.ts @@ -73,6 +73,11 @@ import { isRebasing as isRebasingService, rebase as rebaseService, } from "../services/rebase"; +import type { ResetMode, ResetResult } from "../services/reset"; +import { + resetFile as resetFileService, + resetToCommit as resetToCommitService, +} from "../services/reset"; import type { RevertMode, RevertResult } from "../services/revert"; import { abortRevert as abortRevertService, @@ -191,6 +196,8 @@ interface GitActions { fetchRevertState: () => Promise; abortRevert: () => Promise; continueRevert: () => Promise; + resetToCommit: (oid: string, mode: ResetMode) => Promise; + resetFile: (path: string, oid: string) => Promise; clearError: () => void; } @@ -782,6 +789,24 @@ export const useGitStore = create((set) => ({ } }, + resetToCommit: async (oid: string, mode: ResetMode) => { + try { + return await resetToCommitService(oid, mode); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + resetFile: async (path: string, oid: string) => { + try { + await resetFileService(path, oid); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + clearError: () => { set({ error: null }); }, diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 5efc6e2..50c9d83 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -11,6 +11,8 @@ export type PageId = | "rebase" | "cherry-pick" | "revert" + | "reset" + | "reflog" | "hosting"; interface BlameTarget { From 960763393bb3190a8b2e48b6b3b1b71f0c011835 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:34:06 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(frontend):=20Reset=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft/Mixed/Hard Resetの3モード選択、コミットリスト、変更プレビュー、 Hard Reset確認ダイアログを含む2カラムUIレイアウト。 Co-Authored-By: Claude Opus 4.6 --- src/pages/reset/index.tsx | 135 ++++++++++++++++++ src/pages/reset/molecules/ResetCommitRow.tsx | 45 ++++++ src/pages/reset/organisms/HardResetDialog.tsx | 69 +++++++++ src/pages/reset/organisms/ResetCommitList.tsx | 44 ++++++ src/pages/reset/organisms/ResetOptions.tsx | 67 +++++++++ src/pages/reset/organisms/ResetPreview.tsx | 10 ++ src/styles/reset.css | 112 +++++++++++++++ 7 files changed, 482 insertions(+) create mode 100644 src/pages/reset/index.tsx create mode 100644 src/pages/reset/molecules/ResetCommitRow.tsx create mode 100644 src/pages/reset/organisms/HardResetDialog.tsx create mode 100644 src/pages/reset/organisms/ResetCommitList.tsx create mode 100644 src/pages/reset/organisms/ResetOptions.tsx create mode 100644 src/pages/reset/organisms/ResetPreview.tsx create mode 100644 src/styles/reset.css diff --git a/src/pages/reset/index.tsx b/src/pages/reset/index.tsx new file mode 100644 index 0000000..508ef0c --- /dev/null +++ b/src/pages/reset/index.tsx @@ -0,0 +1,135 @@ +import { useCallback, useState } from "react"; +import { useCommitLog } from "../../hooks/useCommitLog"; +import type { CommitDetail } from "../../services/history"; +import { getCommitDetail } from "../../services/history"; +import type { ResetMode } from "../../services/reset"; +import { useGitStore } from "../../stores/gitStore"; +import { useUIStore } from "../../stores/uiStore"; +import { HardResetDialog } from "./organisms/HardResetDialog"; +import { ResetCommitList } from "./organisms/ResetCommitList"; +import { ResetOptions } from "./organisms/ResetOptions"; +import { ResetPreview } from "./organisms/ResetPreview"; + +export function ResetPage() { + const commits = useCommitLog(); + const [selectedOid, setSelectedOid] = useState(null); + const [mode, setMode] = useState("mixed"); + const [previewDetail, setPreviewDetail] = useState(null); + const [executing, setExecuting] = useState(false); + const [showHardConfirm, setShowHardConfirm] = useState(false); + + const resetToCommit = useGitStore((s) => s.resetToCommit); + const fetchStatus = useGitStore((s) => s.fetchStatus); + const addToast = useUIStore((s) => s.addToast); + + const handleSelect = useCallback( + (oid: string) => { + setSelectedOid(oid); + getCommitDetail(oid) + .then(setPreviewDetail) + .catch((e: unknown) => addToast(String(e), "error")); + }, + [addToast], + ); + + const executeReset = useCallback(async () => { + if (!selectedOid) return; + setExecuting(true); + try { + await resetToCommit(selectedOid, mode); + addToast( + `Reset (${mode}) to ${selectedOid.slice(0, 7)} completed`, + "success", + ); + setSelectedOid(null); + setPreviewDetail(null); + await fetchStatus(); + } catch (e: unknown) { + addToast(`Reset failed: ${String(e)}`, "error"); + } finally { + setExecuting(false); + } + }, [selectedOid, mode, resetToCommit, addToast, fetchStatus]); + + const handleExecute = useCallback(() => { + if (mode === "hard") { + setShowHardConfirm(true); + } else { + executeReset(); + } + }, [mode, executeReset]); + + const handleHardConfirm = useCallback(() => { + setShowHardConfirm(false); + executeReset(); + }, [executeReset]); + + const totalAdd = previewDetail?.stats.additions ?? 0; + const totalDel = previewDetail?.stats.deletions ?? 0; + const fileCount = previewDetail?.stats.files_changed ?? 0; + + return ( +
+
+
+

Reset

+ Move HEAD to a previous commit +
+
+
+
+ + +
+
+ +
+
+
+
+ + {selectedOid ? 1 : 0} commit selected + + {previewDetail && ( + <> + + +{totalAdd} + -{totalDel} + + {fileCount} files + + )} +
+ +
+ {showHardConfirm && selectedOid && ( + setShowHardConfirm(false)} + /> + )} +
+ ); +} diff --git a/src/pages/reset/molecules/ResetCommitRow.tsx b/src/pages/reset/molecules/ResetCommitRow.tsx new file mode 100644 index 0000000..a1bd670 --- /dev/null +++ b/src/pages/reset/molecules/ResetCommitRow.tsx @@ -0,0 +1,45 @@ +import type { CommitInfo } from "../../../services/history"; +import { formatRelativeDate } from "../../../utils/date"; + +interface ResetCommitRowProps { + commit: CommitInfo; + selected: boolean; + onSelect: (oid: string) => void; +} + +export function ResetCommitRow({ + commit, + selected, + onSelect, +}: ResetCommitRowProps) { + return ( + + ); +} diff --git a/src/pages/reset/organisms/HardResetDialog.tsx b/src/pages/reset/organisms/HardResetDialog.tsx new file mode 100644 index 0000000..e67cb36 --- /dev/null +++ b/src/pages/reset/organisms/HardResetDialog.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect } from "react"; + +interface HardResetDialogProps { + commitOid: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function HardResetDialog({ + commitOid, + onConfirm, + onCancel, +}: HardResetDialogProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }, + [onCancel], + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + return ( + <> + {/* biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop dismiss is mouse-only by design */} +
{ + if (e.key === "Escape") onCancel(); + }} + /> +
+
+ Hard Reset + +
+
+

+ This will permanently discard all uncommitted changes and move HEAD + to {commitOid.slice(0, 7)}. +

+

+ This operation cannot be undone. +

+
+
+ + +
+
+ + ); +} diff --git a/src/pages/reset/organisms/ResetCommitList.tsx b/src/pages/reset/organisms/ResetCommitList.tsx new file mode 100644 index 0000000..bc96908 --- /dev/null +++ b/src/pages/reset/organisms/ResetCommitList.tsx @@ -0,0 +1,44 @@ +import { useCommitSearch } from "../../../hooks/useCommitSearch"; +import type { CommitInfo } from "../../../services/history"; +import { ResetCommitRow } from "../molecules/ResetCommitRow"; + +interface ResetCommitListProps { + commits: CommitInfo[]; + selectedOid: string | null; + onSelect: (oid: string) => void; +} + +export function ResetCommitList({ + commits, + selectedOid, + onSelect, +}: ResetCommitListProps) { + const { search, setSearch, filtered } = useCommitSearch(commits); + + return ( +
+
+ Select commit to reset to +
+ setSearch(e.target.value)} + /> +
+
+
+ {filtered.map((commit) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/reset/organisms/ResetOptions.tsx b/src/pages/reset/organisms/ResetOptions.tsx new file mode 100644 index 0000000..7599d26 --- /dev/null +++ b/src/pages/reset/organisms/ResetOptions.tsx @@ -0,0 +1,67 @@ +import type { ResetMode } from "../../../services/reset"; + +interface ResetOptionsProps { + mode: ResetMode; + onModeChange: (mode: ResetMode) => void; +} + +const MODES: { + value: ResetMode; + title: string; + desc: string; + className: string; +}[] = [ + { + value: "soft", + title: "Soft (--soft)", + desc: "Move HEAD only. Changes remain staged.", + className: "", + }, + { + value: "mixed", + title: "Mixed (--mixed)", + desc: "Reset HEAD and index. Changes remain in working tree.", + className: "", + }, + { + value: "hard", + title: "Hard (--hard)", + desc: "Reset everything. All changes will be lost.", + className: "hard", + }, +]; + +export function ResetOptions({ mode, onModeChange }: ResetOptionsProps) { + return ( +
+

Reset Mode

+
+ {MODES.map((m) => { + const classes = [ + "option-mode", + mode === m.value ? "selected" : "", + m.className, + ] + .filter(Boolean) + .join(" "); + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/pages/reset/organisms/ResetPreview.tsx b/src/pages/reset/organisms/ResetPreview.tsx new file mode 100644 index 0000000..1ccd025 --- /dev/null +++ b/src/pages/reset/organisms/ResetPreview.tsx @@ -0,0 +1,10 @@ +import { OperationPreview } from "../../../components/organisms/OperationPreview"; +import type { CommitDetail } from "../../../services/history"; + +interface ResetPreviewProps { + detail: CommitDetail | null; +} + +export function ResetPreview({ detail }: ResetPreviewProps) { + return ; +} diff --git a/src/styles/reset.css b/src/styles/reset.css new file mode 100644 index 0000000..c428d02 --- /dev/null +++ b/src/styles/reset.css @@ -0,0 +1,112 @@ +/* ===== Reset Commit List ===== */ +.reset-commit-list { + flex: 1; + overflow-y: auto; +} + +.reset-commit-row { + display: flex; + align-items: flex-start; + width: 100%; + padding: 14px 16px; + border: none; + border-bottom: 1px solid var(--border); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.reset-commit-row:hover { + background: var(--bg-hover); +} +.reset-commit-row.selected { + background: var(--danger-dim); + border-left: 3px solid var(--danger); +} +.reset-commit-row.selected:hover { + background: var(--danger-dim); +} + +.reset-radio { + margin-right: 12px; + padding-top: 2px; +} +.reset-radio input { + width: 1.15em; + height: 1.15em; + appearance: none; + -webkit-appearance: none; + border-radius: 50%; + border: 0.1em solid #6b6b76; + background: transparent; + display: grid; + place-content: center; + cursor: pointer; +} + +.reset-radio input::before { + content: ""; + width: 0.65em; + height: 0.65em; + border-radius: 50%; + transform: scale(0); + transition: 120ms transform ease-in-out; + background-color: var(--danger); +} + +.reset-radio input:checked { + border-color: var(--danger); +} +.reset-radio input:checked::before { + transform: scale(1); +} + +.reset-graph { + width: 24px; + display: flex; + flex-direction: column; + align-items: center; + margin-right: 12px; +} +.graph-node.reset { + width: 12px; + height: 12px; + background: linear-gradient(135deg, #f87171, #ef4444); + border-radius: 50%; +} + +.reset-info { + flex: 1; + min-width: 0; +} +.reset-message { + font-size: 13px; + font-weight: 500; + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.reset-meta { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-muted); +} +.reset-hash { + font-family: "JetBrains Mono", monospace; + color: var(--danger); +} + +/* ===== Reset Preview Hash ===== */ +.preview-hash.danger { + color: var(--danger); +} + +/* ===== Reset Hard Mode Variant ===== */ +.option-mode.hard.selected { + border-color: var(--danger); +} From 08a6fda0bddd0cc9d3fd8ac5c4859e0cb59562e4 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:34:10 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(frontend):=20Reflog=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEAD移動履歴のテーブル表示、Checkout/Reset to hereアクションボタン、 アクション種別ごとの色分け表示。 Co-Authored-By: Claude Opus 4.6 --- src/pages/reflog/index.tsx | 75 ++++++++++++++ src/pages/reflog/molecules/ReflogRow.tsx | 79 ++++++++++++++ src/pages/reflog/organisms/ReflogTable.tsx | 43 ++++++++ src/styles/reflog.css | 113 +++++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 src/pages/reflog/index.tsx create mode 100644 src/pages/reflog/molecules/ReflogRow.tsx create mode 100644 src/pages/reflog/organisms/ReflogTable.tsx create mode 100644 src/styles/reflog.css diff --git a/src/pages/reflog/index.tsx b/src/pages/reflog/index.tsx new file mode 100644 index 0000000..00a5f53 --- /dev/null +++ b/src/pages/reflog/index.tsx @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ReflogEntry } from "../../services/reflog"; +import { getReflog } from "../../services/reflog"; +import { useGitStore } from "../../stores/gitStore"; +import { useUIStore } from "../../stores/uiStore"; +import { ReflogTable } from "./organisms/ReflogTable"; + +const REFLOG_LIMIT = 100; + +export function ReflogPage() { + const [entries, setEntries] = useState([]); + const checkoutBranch = useGitStore((s) => s.checkoutBranch); + const resetToCommit = useGitStore((s) => s.resetToCommit); + const fetchStatus = useGitStore((s) => s.fetchStatus); + const fetchBranch = useGitStore((s) => s.fetchBranch); + const addToast = useUIStore((s) => s.addToast); + + const loadReflog = useCallback(() => { + getReflog("HEAD", REFLOG_LIMIT) + .then(setEntries) + .catch((e: unknown) => addToast(String(e), "error")); + }, [addToast]); + + useEffect(() => { + loadReflog(); + }, [loadReflog]); + + const handleCheckout = useCallback( + async (oid: string) => { + try { + await checkoutBranch(oid); + addToast(`Checked out ${oid.slice(0, 7)} (detached HEAD)`, "success"); + await fetchBranch(); + await fetchStatus(); + loadReflog(); + } catch (e: unknown) { + addToast(`Checkout failed: ${String(e)}`, "error"); + } + }, + [checkoutBranch, addToast, fetchBranch, fetchStatus, loadReflog], + ); + + const handleResetToHere = useCallback( + async (oid: string) => { + try { + await resetToCommit(oid, "mixed"); + addToast(`Reset (mixed) to ${oid.slice(0, 7)} completed`, "success"); + await fetchStatus(); + await fetchBranch(); + loadReflog(); + } catch (e: unknown) { + addToast(`Reset failed: ${String(e)}`, "error"); + } + }, + [resetToCommit, addToast, fetchStatus, fetchBranch, loadReflog], + ); + + return ( +
+
+
+

Reflog

+ HEAD movement history +
+
+
+ +
+
+ ); +} diff --git a/src/pages/reflog/molecules/ReflogRow.tsx b/src/pages/reflog/molecules/ReflogRow.tsx new file mode 100644 index 0000000..87df692 --- /dev/null +++ b/src/pages/reflog/molecules/ReflogRow.tsx @@ -0,0 +1,79 @@ +import type { ReflogEntry } from "../../../services/reflog"; +import { formatRelativeDate } from "../../../utils/date"; + +interface ReflogRowProps { + entry: ReflogEntry; + onCheckout: (oid: string) => void; + onResetToHere: (oid: string) => void; +} + +function actionClassName(action: string): string { + const lower = action.toLowerCase(); + if (lower.startsWith("commit")) return "commit"; + if (lower.startsWith("checkout")) return "checkout"; + if (lower.startsWith("rebase")) return "rebase"; + if (lower.startsWith("reset")) return "reset"; + if (lower.startsWith("pull")) return "pull"; + if (lower.startsWith("cherry-pick")) return "cherry-pick"; + return ""; +} + +export function ReflogRow({ + entry, + onCheckout, + onResetToHere, +}: ReflogRowProps) { + return ( +
+ HEAD@{`{${entry.index}}`} + {entry.new_short_oid} + + {entry.action} + + {entry.message} + + {formatRelativeDate(entry.committer_date)} + + + + + +
+ ); +} diff --git a/src/pages/reflog/organisms/ReflogTable.tsx b/src/pages/reflog/organisms/ReflogTable.tsx new file mode 100644 index 0000000..d758b40 --- /dev/null +++ b/src/pages/reflog/organisms/ReflogTable.tsx @@ -0,0 +1,43 @@ +import type { ReflogEntry } from "../../../services/reflog"; +import { ReflogRow } from "../molecules/ReflogRow"; + +interface ReflogTableProps { + entries: ReflogEntry[]; + onCheckout: (oid: string) => void; + onResetToHere: (oid: string) => void; +} + +export function ReflogTable({ + entries, + onCheckout, + onResetToHere, +}: ReflogTableProps) { + if (entries.length === 0) { + return ( +
+

No reflog entries found

+
+ ); + } + + return ( +
+
+ Ref + Hash + Action + Message + Date + Actions +
+ {entries.map((entry) => ( + + ))} +
+ ); +} diff --git a/src/styles/reflog.css b/src/styles/reflog.css new file mode 100644 index 0000000..5328138 --- /dev/null +++ b/src/styles/reflog.css @@ -0,0 +1,113 @@ +/* ===== Reflog Content ===== */ +.reflog-content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.reflog-table { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.reflog-header { + display: grid; + grid-template-columns: 100px 80px 100px 1fr 80px 80px; + padding: 12px 16px; + background: var(--bg-tertiary); + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.reflog-row { + display: grid; + grid-template-columns: 100px 80px 100px 1fr 80px 80px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s; +} + +.reflog-row:hover { + background: var(--bg-tertiary); +} + +.reflog-row:last-child { + border-bottom: none; +} + +.reflog-index { + font-family: "JetBrains Mono", monospace; + font-size: 12px; + color: var(--accent); +} + +.reflog-hash { + font-family: "JetBrains Mono", monospace; + font-size: 12px; + color: var(--text-muted); +} + +.reflog-action { + font-size: 12px; + font-weight: 500; +} + +.reflog-action.commit { + color: var(--success); +} +.reflog-action.checkout { + color: var(--accent); +} +.reflog-action.rebase { + color: var(--warning); +} +.reflog-action.reset { + color: var(--danger); +} +.reflog-action.pull { + color: var(--accent); +} +.reflog-action.cherry-pick { + color: var(--pink, #ec4899); +} + +.reflog-message { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.reflog-date { + font-size: 11px; + color: var(--text-muted); +} + +.reflog-actions { + display: flex; + gap: 4px; + justify-content: flex-end; +} + +/* ===== Responsive ===== */ +@media (max-width: 1023px) { + .reflog-header, + .reflog-row { + grid-template-columns: 90px 70px 90px 1fr 70px 70px; + } +} + +@media (max-width: 767px) { + .reflog-header, + .reflog-row { + grid-template-columns: 80px 70px 80px 1fr 70px 70px; + font-size: 11px; + } +} From 70e24e54ef28bd2edf978316c105144faf9e12a3 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:34:15 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat(routing):=20Reset/Reflog=E3=81=AE?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=86=E3=82=A3=E3=83=B3=E3=82=B0=E3=83=BB?= =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=83=89=E3=83=90=E3=83=BC=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdvancedセクションにReset/Reflogナビゲーション項目を追加し、 App.tsxにルーティング、main.tsxにCSSインポートを追加。 Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 4 ++++ src/components/organisms/Sidebar.tsx | 33 ++++++++++++++++++++++++++++ src/main.tsx | 2 ++ 3 files changed, 39 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 4aa83c4..28bda2d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import { FileHistoryPage } from "./pages/file-history"; import { HistoryPage } from "./pages/history"; import { HostingPage } from "./pages/hosting"; import { RebasePage } from "./pages/rebase"; +import { ReflogPage } from "./pages/reflog"; +import { ResetPage } from "./pages/reset"; import { RevertPage } from "./pages/revert"; import { StashPage } from "./pages/stash"; import type { PullOption } from "./services/git"; @@ -185,6 +187,8 @@ export function App() { {activePage === "rebase" && } {activePage === "cherry-pick" && } {activePage === "revert" && } + {activePage === "reset" && } + {activePage === "reflog" && } {activePage === "hosting" && } diff --git a/src/components/organisms/Sidebar.tsx b/src/components/organisms/Sidebar.tsx index 7453460..d71343e 100644 --- a/src/components/organisms/Sidebar.tsx +++ b/src/components/organisms/Sidebar.tsx @@ -131,6 +131,39 @@ export function Sidebar({ changesCount }: SidebarProps) { Rebase + +
Hosting
diff --git a/src/main.tsx b/src/main.tsx index e32bde4..0b30a62 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,8 @@ import "./styles/hosting.css"; import "./styles/operation.css"; import "./styles/cherry-pick.css"; import "./styles/revert.css"; +import "./styles/reset.css"; +import "./styles/reflog.css"; const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); From 2d9c4e8fb27890a8493ede4e721085c2156b8bfa Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:39:59 +0900 Subject: [PATCH 8/9] docs(roadmap): mark Reset/Reflog tasks as completed in v1.2 --- docs/roadmap.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 301042b..70376ff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -394,14 +394,14 @@ **ゴール**: コミット位置の操作と誤操作からの復元ができる -- [ ] Reset - - [ ] Soft Reset(コミットのみ取り消し、変更はステージに残る) - - [ ] Mixed Reset(コミットとステージを取り消し、変更はワーキングツリーに残る) - - [ ] Hard Reset(すべて取り消し)※確認ダイアログ必須 - - [ ] ファイル単位の Reset(特定ファイルを特定コミットの状態に戻す) -- [ ] Reflog - - [ ] HEAD / ブランチの移動履歴表示 - - [ ] Reflog エントリからの復元操作(失われたコミットの救出) +- [x] Reset + - [x] Soft Reset(コミットのみ取り消し、変更はステージに残る) + - [x] Mixed Reset(コミットとステージを取り消し、変更はワーキングツリーに残る) + - [x] Hard Reset(すべて取り消し)※確認ダイアログ必須 + - [x] ファイル単位の Reset(特定ファイルを特定コミットの状態に戻す) +- [x] Reflog + - [x] HEAD / ブランチの移動履歴表示 + - [x] Reflog エントリからの復元操作(失われたコミットの救出) **完動品としての価値**: Reset で柔軟にコミット位置を操作しつつ、Reflog で誤操作からも復帰できる From 4ed391c2d22c5034c3fd87c04a5b2e75c80f1b91 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 20:44:53 +0900 Subject: [PATCH 9/9] style: format reset and reflog code with rustfmt --- src-tauri/src/commands/reset.rs | 6 +----- src-tauri/src/git/git2_backend.rs | 5 +---- src-tauri/tests/git2_backend_test.rs | 14 +++++++++++--- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/reset.rs b/src-tauri/src/commands/reset.rs index 16d8eff..9732853 100644 --- a/src-tauri/src/commands/reset.rs +++ b/src-tauri/src/commands/reset.rs @@ -18,11 +18,7 @@ pub fn reset( } #[tauri::command] -pub fn reset_file( - path: String, - oid: String, - state: State<'_, AppState>, -) -> Result<(), String> { +pub fn reset_file(path: String, oid: String, state: State<'_, AppState>) -> Result<(), String> { let repo_lock = state .repo .lock() diff --git a/src-tauri/src/git/git2_backend.rs b/src-tauri/src/git/git2_backend.rs index 66d2f04..d39b01d 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -2749,10 +2749,7 @@ fn compute_word_diff_pair( /// Format: "action: description" (e.g. "commit: initial commit", "checkout: moving from main to feature") fn parse_reflog_message(message: &str) -> (String, String) { match message.find(": ") { - Some(pos) => ( - message[..pos].to_string(), - message[pos + 2..].to_string(), - ), + Some(pos) => (message[..pos].to_string(), message[pos + 2..].to_string()), None => (message.to_string(), String::new()), } } diff --git a/src-tauri/tests/git2_backend_test.rs b/src-tauri/tests/git2_backend_test.rs index 2ebcb03..2e4ef8a 100644 --- a/src-tauri/tests/git2_backend_test.rs +++ b/src-tauri/tests/git2_backend_test.rs @@ -1975,7 +1975,10 @@ fn reset_soft_moves_head_preserves_staging() { .iter() .filter(|f| f.staging == app_lib::git::types::StagingState::Staged) .collect(); - assert!(!staged_files.is_empty(), "Soft reset should keep changes staged"); + assert!( + !staged_files.is_empty(), + "Soft reset should keep changes staged" + ); } #[test] @@ -2008,7 +2011,9 @@ fn reset_mixed_moves_head_unstages_changes() { let unstaged: Vec<_> = status .files .iter() - .filter(|f| f.path == "mixed.txt" && f.staging == app_lib::git::types::StagingState::Unstaged) + .filter(|f| { + f.path == "mixed.txt" && f.staging == app_lib::git::types::StagingState::Unstaged + }) .collect(); assert!(!unstaged.is_empty(), "Mixed reset should unstage changes"); } @@ -2114,5 +2119,8 @@ fn get_reflog_has_action_and_message() { let entries = backend.get_reflog("HEAD", 10).unwrap(); // The first commit's reflog entry should have "commit" as action let has_commit_action = entries.iter().any(|e| e.action.contains("commit")); - assert!(has_commit_action, "Reflog should contain commit action entries"); + assert!( + has_commit_action, + "Reflog should contain commit action entries" + ); }