From 69b529fee13bd843ac8bab1a39c1f6b5b4dcb55b Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 1 Mar 2026 12:42:28 +0900 Subject: [PATCH 1/5] =?UTF-8?q?roadmap.md=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/roadmap.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index d63cdc5..7cfe41f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -430,15 +430,15 @@ **ゴール**: 複雑なプロジェクト構成を管理できる -- [ ] サブモジュール - - [ ] サブモジュール一覧表示(パス、URL、現在のコミット) - - [ ] サブモジュール追加 - - [ ] サブモジュール更新(同期) - - [ ] サブモジュール削除 -- [ ] ワークツリー - - [ ] ワークツリー一覧表示 - - [ ] ワークツリー追加(ブランチ指定) - - [ ] ワークツリー削除 +- [x] サブモジュール + - [x] サブモジュール一覧表示(パス、URL、現在のコミット) + - [x] サブモジュール追加 + - [x] サブモジュール更新(同期) + - [x] サブモジュール削除 +- [x] ワークツリー + - [x] ワークツリー一覧表示 + - [x] ワークツリー追加(ブランチ指定) + - [x] ワークツリー削除 **完動品としての価値**: モノレポやサブモジュール構成のプロジェクトを GUI で管理できる From 2f3ca6935e1c5169620d38cd50a003ea8188083e Mon Sep 17 00:00:00 2001 From: HMasataka Date: Thu, 5 Mar 2026 19:10:19 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(git):=20Gitconfig=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E3=83=BB=E7=BD=B2=E5=90=8D=E3=82=B3=E3=83=9F?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=83=BB=E7=BD=B2=E5=90=8D=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=81=AE=E3=83=90=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89?= =?UTF-8?q?=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 GitBackendトレイトにgitconfig CRUD 5メソッド・署名検証メソッドを追加し、 commitメソッドにsignパラメータを追加。git2_backendでGPG/SSH署名コミット、 git CLIによる署名検証、gitconfig読み書きを実装。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/git.rs | 5 +- src-tauri/src/commands/gitconfig.rs | 83 ++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/git/backend.rs | 21 +- src-tauri/src/git/error.rs | 6 + src-tauri/src/git/git2_backend.rs | 374 ++++++++++++++++++++++--- src-tauri/src/git/types.rs | 29 ++ src-tauri/src/lib.rs | 5 + src-tauri/tests/git2_backend_test.rs | 92 +++--- src-tauri/tests/tauri_commands_test.rs | 9 +- 10 files changed, 536 insertions(+), 89 deletions(-) create mode 100644 src-tauri/src/commands/gitconfig.rs diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index 44d7f7b..43f56e8 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -82,6 +82,7 @@ pub fn unstage_all(state: State<'_, AppState>) -> Result<(), String> { pub fn commit( message: String, amend: bool, + sign: bool, state: State<'_, AppState>, ) -> Result { let repo_lock = state @@ -89,7 +90,9 @@ pub fn commit( .lock() .map_err(|e| format!("Lock poisoned: {e}"))?; let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.commit(&message, amend).map_err(|e| e.to_string()) + backend + .commit(&message, amend, sign) + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/commands/gitconfig.rs b/src-tauri/src/commands/gitconfig.rs new file mode 100644 index 0000000..f91774c --- /dev/null +++ b/src-tauri/src/commands/gitconfig.rs @@ -0,0 +1,83 @@ +use tauri::State; + +use crate::git::types::{GitConfigEntry, GitConfigScope}; +use crate::state::AppState; + +#[tauri::command] +pub fn get_gitconfig_entries( + scope: GitConfigScope, + 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_gitconfig_entries(scope) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_gitconfig_value( + scope: GitConfigScope, + key: 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 + .get_gitconfig_value(scope, &key) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_gitconfig_value( + scope: GitConfigScope, + key: String, + value: 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 + .set_gitconfig_value(scope, &key, &value) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn unset_gitconfig_value( + scope: GitConfigScope, + key: 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 + .unset_gitconfig_value(scope, &key) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_gitconfig_path( + scope: GitConfigScope, + 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 + .get_gitconfig_path(scope) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6bd6d9e..e20a4a7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod cherry_pick; pub mod config; pub mod conflict; pub mod git; +pub mod gitconfig; pub mod history; pub mod hosting; pub mod rebase; diff --git a/src-tauri/src/git/backend.rs b/src-tauri/src/git/backend.rs index 9eaf5ce..ac555d2 100644 --- a/src-tauri/src/git/backend.rs +++ b/src-tauri/src/git/backend.rs @@ -5,10 +5,10 @@ use crate::git::search::{CodeSearchResult, CommitSearchResult, FilenameSearchRes 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, ReflogEntry, RemoteInfo, - RepoStatus, ResetMode, ResetResult, RevertMode, RevertResult, StashEntry, SubmoduleInfo, - TagInfo, WorktreeInfo, + FileDiff, GitConfigEntry, GitConfigScope, HunkIdentifier, LineRange, LogFilter, + MergeBaseContent, MergeOption, MergeResult, PullOption, PushResult, RebaseResult, RebaseState, + RebaseTodoEntry, ReflogEntry, RemoteInfo, RepoStatus, ResetMode, ResetResult, RevertMode, + RevertResult, SignatureStatus, StashEntry, SubmoduleInfo, TagInfo, WorktreeInfo, }; pub trait GitBackend: Send + Sync { @@ -20,7 +20,7 @@ pub trait GitBackend: Send + Sync { fn stage_all(&self) -> GitResult<()>; fn unstage_all(&self) -> GitResult<()>; fn current_branch(&self) -> GitResult; - fn commit(&self, message: &str, amend: bool) -> GitResult; + fn commit(&self, message: &str, amend: bool, sign: bool) -> GitResult; fn list_branches(&self) -> GitResult>; fn create_branch(&self, name: &str) -> GitResult<()>; fn checkout_branch(&self, name: &str) -> GitResult<()>; @@ -127,4 +127,15 @@ pub trait GitBackend: Send + Sync { fn list_worktrees(&self) -> GitResult>; fn add_worktree(&self, path: &str, branch: &str) -> GitResult<()>; fn remove_worktree(&self, path: &str) -> GitResult<()>; + + // Gitconfig operations + fn get_gitconfig_entries(&self, scope: GitConfigScope) -> GitResult>; + fn get_gitconfig_value(&self, scope: GitConfigScope, key: &str) -> GitResult>; + fn set_gitconfig_value(&self, scope: GitConfigScope, key: &str, value: &str) + -> GitResult<()>; + fn unset_gitconfig_value(&self, scope: GitConfigScope, key: &str) -> GitResult<()>; + fn get_gitconfig_path(&self, scope: GitConfigScope) -> GitResult; + + // Signature verification + fn verify_commit_signatures(&self, oids: &[&str]) -> GitResult>; } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index 1ab3ed2..8013219 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -112,6 +112,12 @@ pub enum GitError { #[error("worktree operation failed: {0}")] WorktreeFailed(#[source] Box), + + #[error("gitconfig operation failed: {0}")] + GitConfigFailed(#[source] Box), + + #[error("signing failed: {0}")] + SigningFailed(#[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 adefb5d..12fe052 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -16,11 +16,11 @@ use crate::git::types::{ CommitFileChange, CommitFileStatus, CommitGraphRow, CommitInfo, CommitLogResult, CommitRef, CommitRefKind, CommitResult, CommitStats, ConflictBlock, ConflictFile, ConflictResolution, DiffHunk, DiffLine, DiffLineKind, DiffOptions, FetchResult, FileDiff, FileStatus, - FileStatusKind, GraphEdge, GraphNodeType, HunkIdentifier, LineRange, LogFilter, - MergeBaseContent, MergeKind, MergeOption, MergeResult, PullOption, PushResult, RebaseAction, - RebaseResult, RebaseState, RebaseTodoEntry, ReflogEntry, RemoteInfo, RepoStatus, ResetMode, - ResetResult, RevertMode, RevertResult, StagingState, StashEntry, SubmoduleInfo, TagInfo, - WordSegment, WorktreeInfo, + FileStatusKind, GitConfigEntry, GitConfigScope, GraphEdge, GraphNodeType, HunkIdentifier, + LineRange, LogFilter, MergeBaseContent, MergeKind, MergeOption, MergeResult, PullOption, + PushResult, RebaseAction, RebaseResult, RebaseState, RebaseTodoEntry, ReflogEntry, RemoteInfo, + RepoStatus, ResetMode, ResetResult, RevertMode, RevertResult, SignatureStatus, StagingState, + StashEntry, SubmoduleInfo, TagInfo, WordSegment, WorktreeInfo, }; pub struct Git2Backend { @@ -260,7 +260,7 @@ impl GitBackend for Git2Backend { Ok(name) } - fn commit(&self, message: &str, amend: bool) -> GitResult { + fn commit(&self, message: &str, amend: bool, sign: bool) -> GitResult { let repo = self.repo.lock().unwrap(); let mut index = repo @@ -287,6 +287,16 @@ impl GitBackend for Git2Backend { .peel_to_commit() .map_err(|e| GitError::AmendFailed(Box::new(e)))?; + if sign { + let parents: Vec = (0..head_commit.parent_count()) + .map(|i| head_commit.parent(i)) + .collect::, _>>() + .map_err(|e| GitError::AmendFailed(Box::new(e)))?; + let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); + + return create_signed_commit(&repo, &sig, message, &tree, &parent_refs); + } + let oid = head_commit .amend( Some("HEAD"), @@ -310,11 +320,15 @@ impl GitBackend for Git2Backend { .map_err(|e| GitError::CommitFailed(Box::new(e)))?; vec![commit] } - Err(_) => vec![], // Initial commit — no parents + Err(_) => vec![], // Initial commit -- no parents }; let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); + if sign { + return create_signed_commit(&repo, &sig, message, &tree, &parent_refs); + } + let oid = repo .commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs) .map_err(|e| GitError::CommitFailed(Box::new(e)))?; @@ -641,42 +655,60 @@ impl GitBackend for Git2Backend { limit: usize, skip: usize, ) -> GitResult { - let repo = self.repo.lock().unwrap(); + let mut commits = { + let repo = self.repo.lock().unwrap(); - let mut revwalk = repo - .revwalk() - .map_err(|e| GitError::LogFailed(Box::new(e)))?; - revwalk - .set_sorting(Sort::TIME | Sort::TOPOLOGICAL) - .map_err(|e| GitError::LogFailed(Box::new(e)))?; - revwalk - .push_head() - .map_err(|e| GitError::LogFailed(Box::new(e)))?; + let mut revwalk = repo + .revwalk() + .map_err(|e| GitError::LogFailed(Box::new(e)))?; + revwalk + .set_sorting(Sort::TIME | Sort::TOPOLOGICAL) + .map_err(|e| GitError::LogFailed(Box::new(e)))?; + revwalk + .push_head() + .map_err(|e| GitError::LogFailed(Box::new(e)))?; - let ref_map = build_ref_map(&repo); - let mut commits = Vec::new(); - let mut skipped = 0; + let ref_map = build_ref_map(&repo); + let mut commits = Vec::new(); + let mut skipped = 0; - for oid_result in revwalk { - let oid = oid_result.map_err(|e| GitError::LogFailed(Box::new(e)))?; - let commit = repo - .find_commit(oid) - .map_err(|e| GitError::LogFailed(Box::new(e)))?; + for oid_result in revwalk { + let oid = oid_result.map_err(|e| GitError::LogFailed(Box::new(e)))?; + let commit = repo + .find_commit(oid) + .map_err(|e| GitError::LogFailed(Box::new(e)))?; - if !matches_filter(&repo, &commit, filter) { - continue; - } + if !matches_filter(&repo, &commit, filter) { + continue; + } - if skipped < skip { - skipped += 1; - continue; + if skipped < skip { + skipped += 1; + continue; + } + + let info = commit_to_info(&commit, &ref_map); + commits.push(info); + + if commits.len() >= limit { + break; + } } - let info = commit_to_info(&commit, &ref_map); - commits.push(info); + commits + }; - if commits.len() >= limit { - break; + let oids: Vec = commits.iter().map(|c| c.oid.clone()).collect(); + let oid_refs: Vec<&str> = oids.iter().map(|s| s.as_str()).collect(); + if let Ok(sig_statuses) = self.verify_commit_signatures(&oid_refs) { + let status_map: std::collections::HashMap<&str, SignatureStatus> = sig_statuses + .iter() + .map(|(oid, status)| (oid.as_str(), *status)) + .collect(); + for commit in &mut commits { + if let Some(&status) = status_map.get(commit.oid.as_str()) { + commit.signature_status = status; + } } } @@ -2076,6 +2108,143 @@ impl GitBackend for Git2Backend { fn remove_worktree(&self, path: &str) -> GitResult<()> { worktree::remove_worktree(&self.workdir, path) } + + fn get_gitconfig_entries(&self, scope: GitConfigScope) -> GitResult> { + let repo = self.repo.lock().unwrap(); + let config = repo + .config() + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + let level = scope_to_level(scope); + let scoped = config + .open_level(level) + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + let mut entries_vec = Vec::new(); + let mut iter = scoped + .entries(None) + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + while let Some(entry) = iter.next() { + let entry = entry.map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + if let (Some(name), Some(value)) = (entry.name(), entry.value()) { + entries_vec.push(GitConfigEntry { + key: name.to_string(), + value: value.to_string(), + }); + } + } + + Ok(entries_vec) + } + + fn get_gitconfig_value(&self, scope: GitConfigScope, key: &str) -> GitResult> { + let repo = self.repo.lock().unwrap(); + let config = repo + .config() + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + let level = scope_to_level(scope); + let scoped = match config.open_level(level) { + Ok(c) => c, + Err(_) => return Ok(None), + }; + + match scoped.get_string(key) { + Ok(v) => Ok(Some(v)), + Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), + Err(e) => Err(GitError::GitConfigFailed(Box::new(e))), + } + } + + fn set_gitconfig_value( + &self, + scope: GitConfigScope, + key: &str, + value: &str, + ) -> GitResult<()> { + let repo = self.repo.lock().unwrap(); + let config = repo + .config() + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + let level = scope_to_level(scope); + let mut scoped = config + .open_level(level) + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + scoped + .set_str(key, value) + .map_err(|e| GitError::GitConfigFailed(Box::new(e))) + } + + fn unset_gitconfig_value(&self, scope: GitConfigScope, key: &str) -> GitResult<()> { + let repo = self.repo.lock().unwrap(); + let config = repo + .config() + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + let level = scope_to_level(scope); + let mut scoped = config + .open_level(level) + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + + match scoped.remove(key) { + Ok(()) => Ok(()), + Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()), + Err(e) => Err(GitError::GitConfigFailed(Box::new(e))), + } + } + + fn get_gitconfig_path(&self, scope: GitConfigScope) -> GitResult { + match scope { + GitConfigScope::Local => { + let repo = self.repo.lock().unwrap(); + let git_dir = repo.path(); + let config_path = git_dir.join("config"); + Ok(config_path.to_string_lossy().to_string()) + } + GitConfigScope::Global => { + let path = git2::Config::find_global() + .map_err(|e| GitError::GitConfigFailed(Box::new(e)))?; + Ok(path.to_string_lossy().to_string()) + } + } + } + + fn verify_commit_signatures( + &self, + oids: &[&str], + ) -> GitResult> { + if oids.is_empty() { + return Ok(Vec::new()); + } + + let output = std::process::Command::new("git") + .args(["log", "--no-walk", "--format=%H %G?"]) + .args(oids) + .current_dir(&self.workdir) + .output() + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SigningFailed(stderr.to_string().into())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut results = Vec::new(); + + for line in stdout.lines() { + let Some((hash, status_char)) = line.split_once(' ') else { + continue; + }; + let status = parse_signature_status(status_char); + results.push((hash.to_string(), status)); + } + + Ok(results) + } } impl Git2Backend { @@ -2501,6 +2670,7 @@ fn commit_to_info(commit: &git2::Commit, ref_map: &RefMap) -> CommitInfo { author_date, parent_oids, refs, + signature_status: SignatureStatus::None, } } @@ -3102,6 +3272,140 @@ fn compute_word_diffs(file_diffs: &mut [FileDiff]) { } } +fn scope_to_level(scope: GitConfigScope) -> git2::ConfigLevel { + match scope { + GitConfigScope::Local => git2::ConfigLevel::Local, + GitConfigScope::Global => git2::ConfigLevel::Global, + } +} + +fn parse_signature_status(status_char: &str) -> SignatureStatus { + match status_char.trim() { + "G" => SignatureStatus::Good, + "B" => SignatureStatus::Bad, + "U" => SignatureStatus::Untrusted, + "X" => SignatureStatus::Expired, + "E" => SignatureStatus::Error, + "N" => SignatureStatus::None, + _ => SignatureStatus::None, + } +} + +fn create_signed_commit( + repo: &Repository, + sig: &git2::Signature, + message: &str, + tree: &git2::Tree, + parents: &[&git2::Commit], +) -> GitResult { + let commit_buf = repo + .commit_create_buffer(sig, sig, message, tree, parents) + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + let commit_content = std::str::from_utf8(&commit_buf) + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + let config = repo + .config() + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + let gpg_format = config.get_string("gpg.format").unwrap_or_else(|_| "openpgp".to_string()); + let signing_key = config + .get_string("user.signingKey") + .map_err(|_| { + GitError::SigningFailed("user.signingKey not configured".into()) + })?; + + let signature = match gpg_format.as_str() { + "ssh" => sign_with_ssh(commit_content, &signing_key)?, + _ => sign_with_gpg(commit_content, &signing_key)?, + }; + + let oid = repo + .commit_signed(commit_content, &signature, Some("gpgsig")) + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + // Update HEAD to point to the new commit + repo.head() + .and_then(|head| { + let refname = head.name().unwrap_or("HEAD"); + repo.reference(refname, oid, true, "commit (signed)") + }) + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + Ok(CommitResult { + oid: oid.to_string(), + }) +} + +fn sign_with_gpg(content: &str, key: &str) -> GitResult { + let mut child = std::process::Command::new("gpg") + .args(["--status-fd=2", "-bsau", key]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(content.as_bytes()) + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + let output = child + .wait_with_output() + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SigningFailed( + format!("GPG signing failed: {}", stderr).into(), + )); + } + + String::from_utf8(output.stdout) + .map_err(|e| GitError::SigningFailed(Box::new(e))) +} + +fn sign_with_ssh(content: &str, key_path: &str) -> GitResult { + let buf_path = std::env::temp_dir().join(format!( + "rocket-ssh-sign-{}", + std::process::id() + )); + + std::fs::write(&buf_path, content.as_bytes()) + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + let sig_path = buf_path.with_extension("sig"); + + let output = std::process::Command::new("ssh-keygen") + .args(["-Y", "sign", "-n", "git", "-f", key_path]) + .arg(&buf_path) + .output() + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + // Clean up temp file regardless of outcome + let _ = std::fs::remove_file(&buf_path); + + if !output.status.success() { + let _ = std::fs::remove_file(&sig_path); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SigningFailed( + format!("SSH signing failed: {}", stderr).into(), + )); + } + + let signature = std::fs::read_to_string(&sig_path) + .map_err(|e| GitError::SigningFailed(Box::new(e)))?; + + let _ = std::fs::remove_file(&sig_path); + + Ok(signature) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 979c46d..4eb33d0 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -179,6 +179,7 @@ pub struct CommitInfo { pub author_date: i64, pub parent_oids: Vec, pub refs: Vec, + pub signature_status: SignatureStatus, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -439,6 +440,34 @@ pub struct RevertResult { pub oid: Option, } +// === Gitconfig types === + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GitConfigScope { + Local, + Global, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitConfigEntry { + pub key: String, + pub value: String, +} + +// === Signature types === + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignatureStatus { + None, + Good, + Bad, + Untrusted, + Expired, + Error, +} + // === Submodule types === #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 843e4a9..9a21229 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -181,6 +181,11 @@ pub fn run() { commands::worktree::list_worktrees, commands::worktree::add_worktree, commands::worktree::remove_worktree, + commands::gitconfig::get_gitconfig_entries, + commands::gitconfig::get_gitconfig_value, + commands::gitconfig::set_gitconfig_value, + commands::gitconfig::unset_gitconfig_value, + commands::gitconfig::get_gitconfig_path, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tests/git2_backend_test.rs b/src-tauri/tests/git2_backend_test.rs index 2e4ef8a..6627e64 100644 --- a/src-tauri/tests/git2_backend_test.rs +++ b/src-tauri/tests/git2_backend_test.rs @@ -32,7 +32,7 @@ fn init_test_repo(dir: &Path) { fn create_file_with_multiple_changed_lines(dir: &Path, backend: &Git2Backend) { fs::write(dir.join("lines.txt"), "line1\nline2\nline3\nline4\nline5\n").unwrap(); backend.stage(Path::new("lines.txt")).unwrap(); - backend.commit("add lines", false).unwrap(); + backend.commit("add lines", false, false).unwrap(); fs::write( dir.join("lines.txt"), "line1\nchanged2\nline3\nchanged4\nline5\nnew6\n", @@ -47,7 +47,7 @@ fn create_file_with_two_hunks(dir: &Path, backend: &Git2Backend) { } fs::write(dir.join("twohunk.txt"), &content).unwrap(); backend.stage(Path::new("twohunk.txt")).unwrap(); - backend.commit("add twohunk", false).unwrap(); + backend.commit("add twohunk", false, false).unwrap(); let mut modified = String::new(); for i in 1..=20 { if i == 2 { @@ -66,7 +66,7 @@ fn init_repo_with_commit(dir: &Path) -> Git2Backend { fs::write(dir.join("init.txt"), "init").unwrap(); let backend = Git2Backend::open(dir).unwrap(); backend.stage(Path::new("init.txt")).unwrap(); - backend.commit("initial commit", false).unwrap(); + backend.commit("initial commit", false, false).unwrap(); backend } @@ -160,7 +160,7 @@ fn commit_creates_oid() { let backend = Git2Backend::open(tmp.path()).unwrap(); backend.stage(Path::new("file.txt")).unwrap(); - let result = backend.commit("initial commit", false).unwrap(); + let result = backend.commit("initial commit", false, false).unwrap(); assert!(!result.oid.is_empty()); } @@ -173,7 +173,7 @@ fn current_branch_after_commit() { let backend = Git2Backend::open(tmp.path()).unwrap(); backend.stage(Path::new("file.txt")).unwrap(); - backend.commit("init", false).unwrap(); + backend.commit("init", false, false).unwrap(); let branch = backend.current_branch().unwrap(); assert!(!branch.is_empty()); @@ -188,7 +188,7 @@ fn diff_unstaged_changes() { let backend = Git2Backend::open(tmp.path()).unwrap(); backend.stage(Path::new("file.txt")).unwrap(); - backend.commit("init", false).unwrap(); + backend.commit("init", false, false).unwrap(); fs::write(tmp.path().join("file.txt"), "line1\nline2\n").unwrap(); @@ -211,7 +211,7 @@ fn diff_staged_changes() { let backend = Git2Backend::open(tmp.path()).unwrap(); backend.stage(Path::new("file.txt")).unwrap(); - backend.commit("init", false).unwrap(); + backend.commit("init", false, false).unwrap(); fs::write(tmp.path().join("file.txt"), "modified\n").unwrap(); backend.stage(Path::new("file.txt")).unwrap(); @@ -301,7 +301,7 @@ fn merge_fast_forward() { fs::write(tmp.path().join("feature.txt"), "feature work").unwrap(); backend.stage(Path::new("feature.txt")).unwrap(); - backend.commit("feature commit", false).unwrap(); + backend.commit("feature commit", false, false).unwrap(); backend.checkout_branch(&default_branch).unwrap(); @@ -441,12 +441,12 @@ fn merge_ff_only_fails_when_not_possible() { backend.checkout_branch("feature").unwrap(); fs::write(tmp.path().join("feature.txt"), "feature").unwrap(); backend.stage(Path::new("feature.txt")).unwrap(); - backend.commit("feature commit", false).unwrap(); + backend.commit("feature commit", false, false).unwrap(); backend.checkout_branch(&default_branch).unwrap(); fs::write(tmp.path().join("main.txt"), "main work").unwrap(); backend.stage(Path::new("main.txt")).unwrap(); - backend.commit("main commit", false).unwrap(); + backend.commit("main commit", false, false).unwrap(); let result = backend.merge_branch("feature", MergeOption::FastForwardOnly); assert!(result.is_err()); @@ -498,7 +498,7 @@ fn get_commit_log_returns_commits() { fs::write(tmp.path().join("second.txt"), "second").unwrap(); backend.stage(Path::new("second.txt")).unwrap(); - backend.commit("second commit", false).unwrap(); + backend.commit("second commit", false, false).unwrap(); let filter = LogFilter { author: None, @@ -549,7 +549,7 @@ fn get_commit_detail_returns_info_and_files() { fs::write(tmp.path().join("detail.txt"), "detail content").unwrap(); backend.stage(Path::new("detail.txt")).unwrap(); - let commit_result = backend.commit("detail commit", false).unwrap(); + let commit_result = backend.commit("detail commit", false, false).unwrap(); let detail = backend.get_commit_detail(&commit_result.oid).unwrap(); @@ -579,7 +579,7 @@ fn get_blame_returns_correct_line_count() { let backend = Git2Backend::open(tmp.path()).unwrap(); backend.stage(Path::new("blame.txt")).unwrap(); - backend.commit("add blame file", false).unwrap(); + backend.commit("add blame file", false, false).unwrap(); let blame_result = backend.get_blame("blame.txt", None).unwrap(); @@ -602,11 +602,11 @@ fn get_blame_with_multiple_commits_tracks_authors() { fs::write(tmp.path().join("multi.txt"), "original\n").unwrap(); let backend = Git2Backend::open(tmp.path()).unwrap(); backend.stage(Path::new("multi.txt")).unwrap(); - let first_commit = backend.commit("first", false).unwrap(); + let first_commit = backend.commit("first", false, false).unwrap(); fs::write(tmp.path().join("multi.txt"), "original\nadded\n").unwrap(); backend.stage(Path::new("multi.txt")).unwrap(); - backend.commit("second", false).unwrap(); + backend.commit("second", false, false).unwrap(); let blame_result = backend.get_blame("multi.txt", None).unwrap(); @@ -623,15 +623,15 @@ fn get_file_history_returns_only_touching_commits() { fs::write(tmp.path().join("tracked.txt"), "v1").unwrap(); backend.stage(Path::new("tracked.txt")).unwrap(); - backend.commit("add tracked", false).unwrap(); + backend.commit("add tracked", false, false).unwrap(); fs::write(tmp.path().join("other.txt"), "other").unwrap(); backend.stage(Path::new("other.txt")).unwrap(); - backend.commit("add other", false).unwrap(); + backend.commit("add other", false, false).unwrap(); fs::write(tmp.path().join("tracked.txt"), "v2").unwrap(); backend.stage(Path::new("tracked.txt")).unwrap(); - backend.commit("update tracked", false).unwrap(); + backend.commit("update tracked", false, false).unwrap(); let history = backend.get_file_history("tracked.txt", 100, 0).unwrap(); @@ -662,7 +662,7 @@ fn get_branch_commits_returns_commits_for_branch() { fs::write(tmp.path().join("feature.txt"), "feature work").unwrap(); backend.stage(Path::new("feature.txt")).unwrap(); - backend.commit("feature commit", false).unwrap(); + backend.commit("feature commit", false, false).unwrap(); let commits = backend.get_branch_commits("feature", 10).unwrap(); @@ -680,7 +680,7 @@ fn get_branch_commits_respects_limit() { let filename = format!("file{i}.txt"); fs::write(tmp.path().join(&filename), format!("content {i}")).unwrap(); backend.stage(Path::new(&filename)).unwrap(); - backend.commit(&format!("commit {i}"), false).unwrap(); + backend.commit(&format!("commit {i}"), false, false).unwrap(); } let branch_name = backend.current_branch().unwrap(); @@ -705,7 +705,7 @@ fn diff_includes_hunk_range_fields() { fs::write(tmp.path().join("file.txt"), "line1\nline2\nline3\n").unwrap(); backend.stage(Path::new("file.txt")).unwrap(); - backend.commit("add file", false).unwrap(); + backend.commit("add file", false, false).unwrap(); fs::write(tmp.path().join("file.txt"), "line1\nmodified\nline3\n").unwrap(); @@ -730,7 +730,7 @@ fn create_two_hunk_file(dir: &Path, backend: &Git2Backend) { } fs::write(dir.join("multi.txt"), &content).unwrap(); backend.stage(Path::new("multi.txt")).unwrap(); - backend.commit("add multi", false).unwrap(); + backend.commit("add multi", false, false).unwrap(); let mut modified = String::new(); for i in 1..=20 { @@ -1075,7 +1075,7 @@ fn commit_amend_changes_message() { let msg = backend.get_head_commit_message().unwrap(); assert_eq!(msg.trim(), "initial commit"); - backend.commit("amended message", true).unwrap(); + backend.commit("amended message", true, false).unwrap(); let msg_after = backend.get_head_commit_message().unwrap(); assert_eq!(msg_after.trim(), "amended message"); @@ -1089,7 +1089,7 @@ fn commit_amend_includes_new_staged_changes() { fs::write(tmp.path().join("new.txt"), "new content").unwrap(); backend.stage(Path::new("new.txt")).unwrap(); - backend.commit("amend with new file", true).unwrap(); + backend.commit("amend with new file", true, false).unwrap(); let detail = backend.get_head_commit_message().unwrap(); assert_eq!(detail.trim(), "amend with new file"); @@ -1306,20 +1306,20 @@ fn setup_conflict_repo(dir: &Path) -> (Git2Backend, String) { // Create shared file on default branch fs::write(dir.join("shared.txt"), "line1\nline2\nline3\n").unwrap(); backend.stage(Path::new("shared.txt")).unwrap(); - backend.commit("add shared", false).unwrap(); + backend.commit("add shared", false, false).unwrap(); // Create feature branch and modify shared.txt backend.create_branch("conflict-branch").unwrap(); backend.checkout_branch("conflict-branch").unwrap(); fs::write(dir.join("shared.txt"), "line1\nfeature-change\nline3\n").unwrap(); backend.stage(Path::new("shared.txt")).unwrap(); - backend.commit("feature change", false).unwrap(); + backend.commit("feature change", false, false).unwrap(); // Go back to default branch and make a conflicting change backend.checkout_branch(&default_branch).unwrap(); fs::write(dir.join("shared.txt"), "line1\nmain-change\nline3\n").unwrap(); backend.stage(Path::new("shared.txt")).unwrap(); - backend.commit("main change", false).unwrap(); + backend.commit("main change", false, false).unwrap(); (backend, default_branch) } @@ -1550,7 +1550,7 @@ fn setup_cherry_pick_repo(dir: &Path) -> (Git2Backend, String) { fs::write(dir.join("cherry.txt"), "cherry content\n").unwrap(); backend.stage(Path::new("cherry.txt")).unwrap(); - backend.commit("feature: add cherry.txt", false).unwrap(); + backend.commit("feature: add cherry.txt", false, false).unwrap(); // Get the OID of the feature commit let log_filter = LogFilter { @@ -1636,7 +1636,7 @@ fn cherry_pick_conflict_and_abort() { // Create a file on main fs::write(tmp.path().join("conflict.txt"), "main content\n").unwrap(); backend.stage(Path::new("conflict.txt")).unwrap(); - backend.commit("main: add conflict.txt", false).unwrap(); + backend.commit("main: add conflict.txt", false, false).unwrap(); // Create feature branch and modify the same file backend.create_branch("feature").unwrap(); @@ -1644,7 +1644,7 @@ fn cherry_pick_conflict_and_abort() { fs::write(tmp.path().join("conflict.txt"), "feature content\n").unwrap(); backend.stage(Path::new("conflict.txt")).unwrap(); backend - .commit("feature: modify conflict.txt", false) + .commit("feature: modify conflict.txt", false, false) .unwrap(); let log_filter = LogFilter { @@ -1662,7 +1662,7 @@ fn cherry_pick_conflict_and_abort() { fs::write(tmp.path().join("conflict.txt"), "different main content\n").unwrap(); backend.stage(Path::new("conflict.txt")).unwrap(); backend - .commit("main: modify conflict.txt differently", false) + .commit("main: modify conflict.txt differently", false, false) .unwrap(); // Cherry-pick should detect conflicts @@ -1712,7 +1712,7 @@ fn revert_auto_mode() { // Create a file and commit fs::write(tmp.path().join("revert_target.txt"), "to be reverted\n").unwrap(); backend.stage(Path::new("revert_target.txt")).unwrap(); - backend.commit("add revert_target.txt", false).unwrap(); + backend.commit("add revert_target.txt", false, false).unwrap(); // Get the OID of the commit to revert let log_filter = LogFilter { @@ -1748,7 +1748,7 @@ fn revert_no_commit_mode() { // Create a file and commit fs::write(tmp.path().join("revert_nc.txt"), "no-commit revert\n").unwrap(); backend.stage(Path::new("revert_nc.txt")).unwrap(); - backend.commit("add revert_nc.txt", false).unwrap(); + backend.commit("add revert_nc.txt", false, false).unwrap(); let log_filter = LogFilter { author: None, @@ -1783,7 +1783,7 @@ fn revert_conflict_and_abort() { // Create a file and commit fs::write(tmp.path().join("revert_conflict.txt"), "original\n").unwrap(); backend.stage(Path::new("revert_conflict.txt")).unwrap(); - backend.commit("add revert_conflict.txt", false).unwrap(); + backend.commit("add revert_conflict.txt", false, false).unwrap(); let log_filter = LogFilter { author: None, @@ -1798,7 +1798,7 @@ fn revert_conflict_and_abort() { // Modify the file in a later commit (this will conflict with reverting the addition) fs::write(tmp.path().join("revert_conflict.txt"), "modified content\n").unwrap(); backend.stage(Path::new("revert_conflict.txt")).unwrap(); - backend.commit("modify revert_conflict.txt", false).unwrap(); + backend.commit("modify revert_conflict.txt", false, false).unwrap(); // Try to revert the original addition commit — should conflict let result = backend.revert(&add_oid, RevertMode::Auto).unwrap(); @@ -1839,7 +1839,7 @@ fn continue_cherry_pick_after_conflict_resolution() { // Create a file on main fs::write(tmp.path().join("conflict.txt"), "main content\n").unwrap(); backend.stage(Path::new("conflict.txt")).unwrap(); - backend.commit("main: add conflict.txt", false).unwrap(); + backend.commit("main: add conflict.txt", false, false).unwrap(); // Create feature branch and modify the same file backend.create_branch("feature").unwrap(); @@ -1847,7 +1847,7 @@ fn continue_cherry_pick_after_conflict_resolution() { fs::write(tmp.path().join("conflict.txt"), "feature content\n").unwrap(); backend.stage(Path::new("conflict.txt")).unwrap(); backend - .commit("feature: modify conflict.txt", false) + .commit("feature: modify conflict.txt", false, false) .unwrap(); let log_filter = LogFilter { @@ -1865,7 +1865,7 @@ fn continue_cherry_pick_after_conflict_resolution() { fs::write(tmp.path().join("conflict.txt"), "different main content\n").unwrap(); backend.stage(Path::new("conflict.txt")).unwrap(); backend - .commit("main: modify conflict.txt differently", false) + .commit("main: modify conflict.txt differently", false, false) .unwrap(); // Cherry-pick should detect conflicts @@ -1899,7 +1899,7 @@ fn continue_revert_after_conflict_resolution() { // Create a file and commit fs::write(tmp.path().join("revert_cont.txt"), "original\n").unwrap(); backend.stage(Path::new("revert_cont.txt")).unwrap(); - backend.commit("add revert_cont.txt", false).unwrap(); + backend.commit("add revert_cont.txt", false, false).unwrap(); let log_filter = LogFilter { author: None, @@ -1914,7 +1914,7 @@ fn continue_revert_after_conflict_resolution() { // Modify the file in a later commit to cause conflict on revert fs::write(tmp.path().join("revert_cont.txt"), "modified content\n").unwrap(); backend.stage(Path::new("revert_cont.txt")).unwrap(); - backend.commit("modify revert_cont.txt", false).unwrap(); + backend.commit("modify revert_cont.txt", false, false).unwrap(); // Revert the original addition — should conflict let result = backend.revert(&add_oid, RevertMode::Auto).unwrap(); @@ -1950,7 +1950,7 @@ fn reset_soft_moves_head_preserves_staging() { // 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(); + backend.commit("second commit", false, false).unwrap(); let log_filter = LogFilter { author: None, @@ -1988,7 +1988,7 @@ fn reset_mixed_moves_head_unstages_changes() { fs::write(tmp.path().join("mixed.txt"), "mixed content").unwrap(); backend.stage(Path::new("mixed.txt")).unwrap(); - backend.commit("mixed commit", false).unwrap(); + backend.commit("mixed commit", false, false).unwrap(); let log_filter = LogFilter { author: None, @@ -2025,7 +2025,7 @@ fn reset_hard_discards_all_changes() { fs::write(tmp.path().join("hard.txt"), "hard content").unwrap(); backend.stage(Path::new("hard.txt")).unwrap(); - backend.commit("hard commit", false).unwrap(); + backend.commit("hard commit", false, false).unwrap(); let log_filter = LogFilter { author: None, @@ -2052,7 +2052,7 @@ fn reset_file_restores_file_in_index() { // 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(); + backend.commit("add resetfile", false, false).unwrap(); let log_filter = LogFilter { author: None, @@ -2100,11 +2100,11 @@ fn get_reflog_respects_limit() { // 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(); + backend.commit("commit a", false, false).unwrap(); fs::write(tmp.path().join("b.txt"), "b").unwrap(); backend.stage(Path::new("b.txt")).unwrap(); - backend.commit("commit b", false).unwrap(); + backend.commit("commit b", false, false).unwrap(); // Limit to 2 entries let entries = backend.get_reflog("HEAD", 2).unwrap(); diff --git a/src-tauri/tests/tauri_commands_test.rs b/src-tauri/tests/tauri_commands_test.rs index 7a4f813..d6ea993 100644 --- a/src-tauri/tests/tauri_commands_test.rs +++ b/src-tauri/tests/tauri_commands_test.rs @@ -33,7 +33,7 @@ fn init_repo_with_commit(dir: &Path) -> Box { fs::write(dir.join("init.txt"), "init").unwrap(); let backend = Git2Backend::open(dir).unwrap(); backend.stage(Path::new("init.txt")).unwrap(); - backend.commit("initial commit", false).unwrap(); + backend.commit("initial commit", false, false).unwrap(); Box::new(backend) } @@ -102,6 +102,11 @@ fn build_test_app(state: AppState) -> tauri::App { commands::rebase::get_rebase_state, commands::rebase::get_rebase_todo, commands::rebase::get_merge_base_content, + commands::gitconfig::get_gitconfig_entries, + commands::gitconfig::get_gitconfig_value, + commands::gitconfig::set_gitconfig_value, + commands::gitconfig::unset_gitconfig_value, + commands::gitconfig::get_gitconfig_path, ]) .build(tauri::generate_context!()) .unwrap() @@ -316,7 +321,7 @@ fn test_commit() { // When: commit is called let request = make_request( "commit", - serde_json::json!({ "message": "test commit", "amend": false }), + serde_json::json!({ "message": "test commit", "amend": false, "sign": false }), ); let body = tauri::test::get_ipc_response(&webview, request).expect("commit should succeed"); From 2d0adc66e0bb85c342a983bfd6aca1a87c047e47 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Thu, 5 Mar 2026 19:10:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(frontend):=20Gitconfig=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=82=BF=E3=83=96=E3=81=AEIPC=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=93=E3=82=B9=E3=81=A8UI=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitconfig IPCサービス(5関数)、SettingsGitConfigTabコンポーネント (Local/Global切替、User/Commit/Core/Pull&Push/Merge/Aliasesセクション)、 DeferredInputによるonBlur保存、設定モーダルへのタブ追加を実装。 Co-Authored-By: Claude Opus 4.6 --- .../organisms/SettingsGitConfigTab.tsx | 324 ++++++++++++++++++ src/components/organisms/SettingsModal.tsx | 11 +- src/main.tsx | 1 + src/services/__tests__/gitconfig.test.ts | 101 ++++++ src/services/gitconfig.ts | 40 +++ src/styles/gitconfig.css | 105 ++++++ 6 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 src/components/organisms/SettingsGitConfigTab.tsx create mode 100644 src/services/__tests__/gitconfig.test.ts create mode 100644 src/services/gitconfig.ts create mode 100644 src/styles/gitconfig.css diff --git a/src/components/organisms/SettingsGitConfigTab.tsx b/src/components/organisms/SettingsGitConfigTab.tsx new file mode 100644 index 0000000..b4c7059 --- /dev/null +++ b/src/components/organisms/SettingsGitConfigTab.tsx @@ -0,0 +1,324 @@ +import { + type ChangeEvent, + type FocusEvent, + useCallback, + useEffect, + useState, +} from "react"; +import type { GitConfigEntry, GitConfigScope } from "../../services/gitconfig"; +import { + getGitconfigEntries, + getGitconfigPath, + setGitconfigValue, + unsetGitconfigValue, +} from "../../services/gitconfig"; +import { useUIStore } from "../../stores/uiStore"; + +interface ConfigSection { + title: string; + keys: ConfigKeyDef[]; +} + +interface ConfigKeyDef { + key: string; + type: "text" | "select" | "toggle"; + placeholder?: string; + options?: string[]; +} + +const SECTIONS: ConfigSection[] = [ + { + title: "User", + keys: [ + { key: "user.name", type: "text" }, + { key: "user.email", type: "text" }, + { + key: "user.signingKey", + type: "text", + placeholder: "GPG or SSH key ID", + }, + ], + }, + { + title: "Commit", + keys: [ + { + key: "gpg.format", + type: "select", + options: ["openpgp", "ssh", "x509"], + }, + { key: "commit.gpgSign", type: "toggle" }, + { + key: "commit.template", + type: "text", + placeholder: "~/.gitmessage", + }, + ], + }, + { + title: "Core", + keys: [ + { key: "core.editor", type: "text" }, + { + key: "core.autocrlf", + type: "select", + options: ["input", "true", "false"], + }, + { + key: "core.excludesFile", + type: "text", + placeholder: "~/.gitignore_global", + }, + ], + }, + { + title: "Pull & Push", + keys: [ + { + key: "pull.rebase", + type: "select", + options: ["false", "true", "merges"], + }, + { + key: "push.default", + type: "select", + options: ["simple", "current", "upstream", "matching"], + }, + { key: "push.autoSetupRemote", type: "toggle" }, + ], + }, + { + title: "Merge", + keys: [ + { + key: "merge.ff", + type: "select", + options: ["true", "false", "only"], + }, + { + key: "merge.conflictStyle", + type: "select", + options: ["merge", "diff3", "zdiff3"], + }, + ], + }, +]; + +interface DeferredInputProps { + id: string; + value: string; + placeholder?: string; + onCommit: (value: string) => void; +} + +function DeferredInput({ + id, + value, + placeholder, + onCommit, +}: DeferredInputProps) { + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleBlur = (e: FocusEvent) => { + if (e.target.value !== value) { + onCommit(e.target.value); + } + }; + + return ( + ) => + setLocalValue(e.target.value) + } + onBlur={handleBlur} + /> + ); +} + +export function SettingsGitConfigTab() { + const [scope, setScope] = useState("local"); + const [configPath, setConfigPath] = useState(""); + const [entries, setEntries] = useState>(new Map()); + const [aliases, setAliases] = useState([]); + const addToast = useUIStore((s) => s.addToast); + + const loadEntries = useCallback( + async (s: GitConfigScope) => { + try { + const [allEntries, path] = await Promise.all([ + getGitconfigEntries(s), + getGitconfigPath(s), + ]); + const map = new Map(); + const aliasList: GitConfigEntry[] = []; + for (const entry of allEntries) { + if (entry.key.startsWith("alias.")) { + aliasList.push({ + key: entry.key.replace("alias.", ""), + value: entry.value, + }); + } + map.set(entry.key, entry.value); + } + setEntries(map); + setAliases(aliasList); + setConfigPath(path); + } catch (e) { + addToast(String(e), "error"); + } + }, + [addToast], + ); + + useEffect(() => { + loadEntries(scope); + }, [scope, loadEntries]); + + const handleScopeChange = (newScope: GitConfigScope) => { + setScope(newScope); + }; + + const handleValueChange = useCallback( + async (key: string, value: string) => { + try { + if (value === "") { + await unsetGitconfigValue(scope, key); + } else { + await setGitconfigValue(scope, key, value); + } + setEntries((prev) => { + const next = new Map(prev); + if (value === "") { + next.delete(key); + } else { + next.set(key, value); + } + return next; + }); + } catch (e) { + addToast(String(e), "error"); + } + }, + [scope, addToast], + ); + + const handleToggle = useCallback( + async (key: string) => { + const current = entries.get(key); + const next = current === "true" ? "false" : "true"; + await handleValueChange(key, next); + }, + [entries, handleValueChange], + ); + + const renderField = (def: ConfigKeyDef) => { + const value = entries.get(def.key) ?? ""; + + if (def.type === "toggle") { + return ( + + ); + } + + const fieldId = `gitconfig-${def.key.replace(/\./g, "-")}`; + + if (def.type === "select" && def.options) { + return ( + + ); + } + + return ( + handleValueChange(def.key, v)} + /> + ); + }; + + return ( +
+
+
+ + +
+ {configPath} +
+ + {SECTIONS.map((section) => ( +
+

{section.title}

+ {section.keys.map((def) => ( +
+ + {renderField(def)} +
+ ))} +
+ ))} + + {aliases.length > 0 && ( +
+

Aliases

+
+ {aliases.map((alias) => ( +
+ {alias.key} + {alias.value} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/organisms/SettingsModal.tsx b/src/components/organisms/SettingsModal.tsx index eddd3a6..b0ed0bf 100644 --- a/src/components/organisms/SettingsModal.tsx +++ b/src/components/organisms/SettingsModal.tsx @@ -3,14 +3,22 @@ import { Modal } from "./Modal"; import { SettingsAiTab } from "./SettingsAiTab"; import { SettingsAppearanceTab } from "./SettingsAppearanceTab"; import { SettingsEditorTab } from "./SettingsEditorTab"; +import { SettingsGitConfigTab } from "./SettingsGitConfigTab"; import { SettingsKeybindingsTab } from "./SettingsKeybindingsTab"; import { SettingsToolsTab } from "./SettingsToolsTab"; -type SettingsTabId = "appearance" | "editor" | "keybindings" | "tools" | "ai"; +type SettingsTabId = + | "appearance" + | "editor" + | "gitconfig" + | "keybindings" + | "tools" + | "ai"; const TABS: { id: SettingsTabId; label: string }[] = [ { id: "appearance", label: "Appearance" }, { id: "editor", label: "Editor" }, + { id: "gitconfig", label: "Git Config" }, { id: "keybindings", label: "Keybindings" }, { id: "tools", label: "External Tools" }, { id: "ai", label: "AI Settings" }, @@ -41,6 +49,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
{activeTab === "appearance" && } {activeTab === "editor" && } + {activeTab === "gitconfig" && } {activeTab === "keybindings" && } {activeTab === "tools" && } {activeTab === "ai" && } diff --git a/src/main.tsx b/src/main.tsx index 7dcb628..e3797e8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,6 +16,7 @@ import "./styles/tags.css"; import "./styles/conflict.css"; import "./styles/ai.css"; import "./styles/settings.css"; +import "./styles/gitconfig.css"; import "./styles/hosting.css"; import "./styles/operation.css"; import "./styles/cherry-pick.css"; diff --git a/src/services/__tests__/gitconfig.test.ts b/src/services/__tests__/gitconfig.test.ts new file mode 100644 index 0000000..3670a9f --- /dev/null +++ b/src/services/__tests__/gitconfig.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { + getGitconfigEntries, + getGitconfigPath, + getGitconfigValue, + setGitconfigValue, + unsetGitconfigValue, +} from "../gitconfig"; + +const mockedInvoke = vi.mocked(invoke); + +describe("gitconfig service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getGitconfigEntries", () => { + it("invokes with correct scope", async () => { + const mockEntries = [ + { key: "user.name", value: "Test" }, + { key: "user.email", value: "test@example.com" }, + ]; + mockedInvoke.mockResolvedValueOnce(mockEntries); + + const result = await getGitconfigEntries("local"); + + expect(mockedInvoke).toHaveBeenCalledWith("get_gitconfig_entries", { + scope: "local", + }); + expect(result).toEqual(mockEntries); + }); + }); + + describe("getGitconfigValue", () => { + it("invokes with scope and key", async () => { + mockedInvoke.mockResolvedValueOnce("Test User"); + + const result = await getGitconfigValue("global", "user.name"); + + expect(mockedInvoke).toHaveBeenCalledWith("get_gitconfig_value", { + scope: "global", + key: "user.name", + }); + expect(result).toBe("Test User"); + }); + + it("returns null for missing key", async () => { + mockedInvoke.mockResolvedValueOnce(null); + + const result = await getGitconfigValue("local", "nonexistent.key"); + + expect(result).toBeNull(); + }); + }); + + describe("setGitconfigValue", () => { + it("invokes with scope, key, and value", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await setGitconfigValue("local", "user.name", "New Name"); + + expect(mockedInvoke).toHaveBeenCalledWith("set_gitconfig_value", { + scope: "local", + key: "user.name", + value: "New Name", + }); + }); + }); + + describe("unsetGitconfigValue", () => { + it("invokes with scope and key", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await unsetGitconfigValue("global", "user.signingKey"); + + expect(mockedInvoke).toHaveBeenCalledWith("unset_gitconfig_value", { + scope: "global", + key: "user.signingKey", + }); + }); + }); + + describe("getGitconfigPath", () => { + it("invokes with scope", async () => { + mockedInvoke.mockResolvedValueOnce("/home/user/.gitconfig"); + + const result = await getGitconfigPath("global"); + + expect(mockedInvoke).toHaveBeenCalledWith("get_gitconfig_path", { + scope: "global", + }); + expect(result).toBe("/home/user/.gitconfig"); + }); + }); +}); diff --git a/src/services/gitconfig.ts b/src/services/gitconfig.ts new file mode 100644 index 0000000..b55b9d1 --- /dev/null +++ b/src/services/gitconfig.ts @@ -0,0 +1,40 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type GitConfigScope = "local" | "global"; + +export interface GitConfigEntry { + key: string; + value: string; +} + +export function getGitconfigEntries( + scope: GitConfigScope, +): Promise { + return invoke("get_gitconfig_entries", { scope }); +} + +export function getGitconfigValue( + scope: GitConfigScope, + key: string, +): Promise { + return invoke("get_gitconfig_value", { scope, key }); +} + +export function setGitconfigValue( + scope: GitConfigScope, + key: string, + value: string, +): Promise { + return invoke("set_gitconfig_value", { scope, key, value }); +} + +export function unsetGitconfigValue( + scope: GitConfigScope, + key: string, +): Promise { + return invoke("unset_gitconfig_value", { scope, key }); +} + +export function getGitconfigPath(scope: GitConfigScope): Promise { + return invoke("get_gitconfig_path", { scope }); +} diff --git a/src/styles/gitconfig.css b/src/styles/gitconfig.css new file mode 100644 index 0000000..2e132b5 --- /dev/null +++ b/src/styles/gitconfig.css @@ -0,0 +1,105 @@ +/* ===== Git Config Scope Bar ===== */ +.gitconfig-scope-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; +} + +.gitconfig-scope-toggle { + display: flex; + background: var(--bg-secondary); + border-radius: 6px; + padding: 2px; +} + +.gitconfig-scope-btn { + padding: 4px 12px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + font-family: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.gitconfig-scope-btn:hover { + color: var(--text-secondary); +} + +.gitconfig-scope-btn.active { + background: var(--accent); + color: #fff; +} + +.gitconfig-scope-path { + font-family: "JetBrains Mono", monospace; + font-size: 11px; + color: var(--text-muted); +} + +/* ===== Git Config Rows ===== */ +.gitconfig-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 0; +} + +.gitconfig-row + .gitconfig-row { + border-top: 1px solid var(--border); +} + +.gitconfig-key { + font-family: "JetBrains Mono", monospace; + font-size: 12px; + color: var(--text-secondary); + min-width: 180px; + flex-shrink: 0; +} + +.gitconfig-row .settings-input { + flex: 1; +} + +.gitconfig-row .modal-select { + min-width: 140px; +} + +/* ===== Alias List ===== */ +.gitconfig-alias-list { + display: flex; + flex-direction: column; +} + +.gitconfig-alias-row { + display: flex; + align-items: center; + gap: 16px; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +.gitconfig-alias-row:last-child { + border-bottom: none; +} + +.gitconfig-alias-name { + font-family: "JetBrains Mono", monospace; + font-size: 12px; + font-weight: 600; + color: var(--accent); + min-width: 60px; +} + +.gitconfig-alias-value { + font-family: "JetBrains Mono", monospace; + font-size: 12px; + color: var(--text-muted); +} From c008d8205cb91491629d353c67042f7a4750887f Mon Sep 17 00:00:00 2001 From: HMasataka Date: Thu, 5 Mar 2026 19:10:41 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(frontend):=20=E7=BD=B2=E5=90=8D?= =?UTF-8?q?=E3=83=88=E3=82=B0=E3=83=AB=E3=83=BB=E7=BD=B2=E5=90=8D=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=B8=E3=81=AEUI=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommitPanelにSignチェックボックス追加、commitChanges/gitStoreにsignパラメータ伝搬、 CommitRowに署名バッジ(Verified/Invalid/Untrusted/Expired)表示を実装。 SignatureStatus型をhistoryサービスに追加。 Co-Authored-By: Claude Opus 4.6 --- src/pages/changes/index.tsx | 4 +- src/pages/changes/organisms/CommitPanel.tsx | 13 ++++++- src/pages/history/molecules/CommitRow.tsx | 43 ++++++++++++++++++++- src/services/git.ts | 3 +- src/services/history.ts | 9 +++++ src/stores/__tests__/gitStore.test.ts | 26 ++++++++++++- src/stores/gitStore.ts | 6 +-- src/styles/history.css | 37 ++++++++++++++++++ 8 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/pages/changes/index.tsx b/src/pages/changes/index.tsx index 82a094c..408c0c8 100644 --- a/src/pages/changes/index.tsx +++ b/src/pages/changes/index.tsx @@ -76,9 +76,9 @@ export function ChangesPage() { }, [unstageAllAction, fetchStatus]); const handleCommit = useCallback( - async (message: string, amend: boolean) => { + async (message: string, amend: boolean, sign: boolean) => { try { - await commitAction(message, amend); + await commitAction(message, amend, sign); addToast( amend ? "Commit amended successfully" : "Commit created successfully", "success", diff --git a/src/pages/changes/organisms/CommitPanel.tsx b/src/pages/changes/organisms/CommitPanel.tsx index 1a5dfc5..6883137 100644 --- a/src/pages/changes/organisms/CommitPanel.tsx +++ b/src/pages/changes/organisms/CommitPanel.tsx @@ -4,7 +4,7 @@ import { useAiStore } from "../../../stores/aiStore"; import { useUIStore } from "../../../stores/uiStore"; interface CommitPanelProps { - onCommit: (message: string, amend: boolean) => void; + onCommit: (message: string, amend: boolean, sign: boolean) => void; hasStagedFiles: boolean; hasChanges: boolean; onLoadHeadMessage?: () => Promise; @@ -19,6 +19,7 @@ export function CommitPanel({ const [subject, setSubject] = useState(""); const [body, setBody] = useState(""); const [amend, setAmend] = useState(false); + const [sign, setSign] = useState(false); const generating = useAiStore((s) => s.generating); const reviewing = useAiStore((s) => s.reviewing); @@ -29,7 +30,7 @@ export function CommitPanel({ const handleCommit = () => { const message = body.trim() ? `${subject}\n\n${body}` : subject; - onCommit(message, amend); + onCommit(message, amend, sign); setSubject(""); setBody(""); setAmend(false); @@ -82,6 +83,14 @@ export function CommitPanel({
Commit +