From 2f8522a6ab2d7ec03f407299212eb202b9c6a6a9 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 28 Feb 2026 12:42:24 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(git):=20=E3=82=B5=E3=83=96=E3=83=A2?= =?UTF-8?q?=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=83=BB=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=84=E3=83=AA=E3=83=BC=E3=81=AE=E5=9E=8B=E5=AE=9A?= =?UTF-8?q?=E7=BE=A9=E3=83=BB=E3=83=88=E3=83=AC=E3=82=A4=E3=83=88=E3=83=BB?= =?UTF-8?q?git2=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 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/git/backend.rs | 15 +- src-tauri/src/git/error.rs | 6 + src-tauri/src/git/git2_backend.rs | 37 ++- src-tauri/src/git/mod.rs | 2 + src-tauri/src/git/submodule.rs | 359 ++++++++++++++++++++++++++++++ src-tauri/src/git/types.rs | 33 +++ src-tauri/src/git/worktree.rs | 235 +++++++++++++++++++ 7 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/git/submodule.rs create mode 100644 src-tauri/src/git/worktree.rs diff --git a/src-tauri/src/git/backend.rs b/src-tauri/src/git/backend.rs index 3f11ea3..9eaf5ce 100644 --- a/src-tauri/src/git/backend.rs +++ b/src-tauri/src/git/backend.rs @@ -7,7 +7,8 @@ use crate::git::types::{ 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, TagInfo, + RepoStatus, ResetMode, ResetResult, RevertMode, RevertResult, StashEntry, SubmoduleInfo, + TagInfo, WorktreeInfo, }; pub trait GitBackend: Send + Sync { @@ -114,4 +115,16 @@ pub trait GitBackend: Send + Sync { fn search_code(&self, query: &str, is_regex: bool) -> GitResult>; fn search_commits(&self, query: &str, search_diff: bool) -> GitResult>; fn search_filenames(&self, query: &str) -> GitResult>; + + // Submodule operations + fn list_submodules(&self) -> GitResult>; + fn add_submodule(&self, url: &str, path: &str) -> GitResult<()>; + fn update_submodule(&self, path: &str) -> GitResult<()>; + fn update_all_submodules(&self) -> GitResult<()>; + fn remove_submodule(&self, path: &str) -> GitResult<()>; + + // Worktree operations + fn list_worktrees(&self) -> GitResult>; + fn add_worktree(&self, path: &str, branch: &str) -> GitResult<()>; + fn remove_worktree(&self, path: &str) -> GitResult<()>; } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index 9bb9e0d..1ab3ed2 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -106,6 +106,12 @@ pub enum GitError { #[error("search failed: {0}")] SearchFailed(#[source] Box), + + #[error("submodule operation failed: {0}")] + SubmoduleFailed(#[source] Box), + + #[error("worktree operation failed: {0}")] + WorktreeFailed(#[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 abba855..adefb5d 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -9,6 +9,8 @@ use crate::git::auth::create_credentials_callback; use crate::git::backend::GitBackend; use crate::git::error::{GitError, GitResult}; use crate::git::search::{self, CodeSearchResult, CommitSearchResult, FilenameSearchResult}; +use crate::git::submodule; +use crate::git::worktree; use crate::git::types::{ BlameLine, BlameResult, BranchInfo, CherryPickMode, CherryPickResult, CommitDetail, CommitFileChange, CommitFileStatus, CommitGraphRow, CommitInfo, CommitLogResult, CommitRef, @@ -17,7 +19,8 @@ use crate::git::types::{ 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, TagInfo, WordSegment, + ResetResult, RevertMode, RevertResult, StagingState, StashEntry, SubmoduleInfo, TagInfo, + WordSegment, WorktreeInfo, }; pub struct Git2Backend { @@ -2041,6 +2044,38 @@ impl GitBackend for Git2Backend { fn search_filenames(&self, query: &str) -> GitResult> { search::search_filenames(&self.workdir, query) } + + fn list_submodules(&self) -> GitResult> { + submodule::list_submodules(&self.workdir) + } + + fn add_submodule(&self, url: &str, path: &str) -> GitResult<()> { + submodule::add_submodule(&self.workdir, url, path) + } + + fn update_submodule(&self, path: &str) -> GitResult<()> { + submodule::update_submodule(&self.workdir, path) + } + + fn update_all_submodules(&self) -> GitResult<()> { + submodule::update_all_submodules(&self.workdir) + } + + fn remove_submodule(&self, path: &str) -> GitResult<()> { + submodule::remove_submodule(&self.workdir, path) + } + + fn list_worktrees(&self) -> GitResult> { + worktree::list_worktrees(&self.workdir) + } + + fn add_worktree(&self, path: &str, branch: &str) -> GitResult<()> { + worktree::add_worktree(&self.workdir, path, branch) + } + + fn remove_worktree(&self, path: &str) -> GitResult<()> { + worktree::remove_worktree(&self.workdir, path) + } } impl Git2Backend { diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 948b329..bc13c32 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -4,4 +4,6 @@ pub mod dispatcher; pub mod error; pub mod git2_backend; pub mod search; +pub mod submodule; pub mod types; +pub mod worktree; diff --git a/src-tauri/src/git/submodule.rs b/src-tauri/src/git/submodule.rs new file mode 100644 index 0000000..4fd9e91 --- /dev/null +++ b/src-tauri/src/git/submodule.rs @@ -0,0 +1,359 @@ +use std::path::Path; +use std::process::Command; + +use crate::git::error::{GitError, GitResult}; +use crate::git::types::{SubmoduleInfo, SubmoduleStatus}; + +pub fn list_submodules(workdir: &Path) -> GitResult> { + let status_output = Command::new("git") + .current_dir(workdir) + .args(["submodule", "status"]) + .output() + .map_err(|e| GitError::SubmoduleFailed(Box::new(e)))?; + + if !status_output.status.success() { + let stderr = String::from_utf8_lossy(&status_output.stderr); + return Err(GitError::SubmoduleFailed(stderr.to_string().into())); + } + + let status_stdout = String::from_utf8_lossy(&status_output.stdout); + let mut submodules = parse_submodule_status(&status_stdout); + + let gitmodules = read_gitmodules(workdir); + enrich_from_gitmodules(&mut submodules, &gitmodules); + + Ok(submodules) +} + +pub fn add_submodule(workdir: &Path, url: &str, path: &str) -> GitResult<()> { + let output = Command::new("git") + .current_dir(workdir) + .args(["submodule", "add", url, path]) + .output() + .map_err(|e| GitError::SubmoduleFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SubmoduleFailed(stderr.to_string().into())); + } + + Ok(()) +} + +pub fn update_submodule(workdir: &Path, path: &str) -> GitResult<()> { + let output = Command::new("git") + .current_dir(workdir) + .args(["submodule", "update", "--remote", path]) + .output() + .map_err(|e| GitError::SubmoduleFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SubmoduleFailed(stderr.to_string().into())); + } + + Ok(()) +} + +pub fn update_all_submodules(workdir: &Path) -> GitResult<()> { + let output = Command::new("git") + .current_dir(workdir) + .args(["submodule", "update", "--remote"]) + .output() + .map_err(|e| GitError::SubmoduleFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SubmoduleFailed(stderr.to_string().into())); + } + + Ok(()) +} + +pub fn remove_submodule(workdir: &Path, path: &str) -> GitResult<()> { + let deinit = Command::new("git") + .current_dir(workdir) + .args(["submodule", "deinit", "-f", path]) + .output() + .map_err(|e| GitError::SubmoduleFailed(Box::new(e)))?; + + if !deinit.status.success() { + let stderr = String::from_utf8_lossy(&deinit.stderr); + return Err(GitError::SubmoduleFailed(stderr.to_string().into())); + } + + let rm = Command::new("git") + .current_dir(workdir) + .args(["rm", "-f", path]) + .output() + .map_err(|e| GitError::SubmoduleFailed(Box::new(e)))?; + + if !rm.status.success() { + let stderr = String::from_utf8_lossy(&rm.stderr); + return Err(GitError::SubmoduleFailed(stderr.to_string().into())); + } + + let modules_path = workdir.join(".git").join("modules").join(path); + if modules_path.exists() { + std::fs::remove_dir_all(&modules_path) + .map_err(|e| GitError::SubmoduleFailed(Box::new(e)))?; + } + + Ok(()) +} + +/// Parse `git submodule status` output. +/// Each line format: `[ +-U] ()` +/// Prefix: ' ' = up-to-date, '+' = modified, '-' = uninitialized +fn parse_submodule_status(output: &str) -> Vec { + output + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + let (status, rest) = parse_status_prefix(trimmed); + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + if parts.is_empty() { + return None; + } + + let oid = parts[0].to_string(); + let short_oid = if oid.len() >= 7 { + oid[..7].to_string() + } else { + oid.clone() + }; + + let path = if parts.len() > 1 { + // Path may be followed by a describe in parentheses + parts[1].split(' ').next().unwrap_or(parts[1]).to_string() + } else { + return None; + }; + + let (head_oid, head_short_oid) = if status == SubmoduleStatus::Uninitialized { + (None, None) + } else { + (Some(oid), Some(short_oid)) + }; + + Some(SubmoduleInfo { + path, + url: String::new(), + branch: None, + head_oid, + head_short_oid, + status, + }) + }) + .collect() +} + +fn parse_status_prefix(line: &str) -> (SubmoduleStatus, &str) { + match line.as_bytes().first() { + Some(b'+') => (SubmoduleStatus::Modified, &line[1..]), + Some(b'-') => (SubmoduleStatus::Uninitialized, &line[1..]), + Some(b'U') => (SubmoduleStatus::Conflict, &line[1..]), + Some(b' ') => (SubmoduleStatus::UpToDate, &line[1..]), + _ => (SubmoduleStatus::UpToDate, line), + } +} + +fn read_gitmodules(workdir: &Path) -> String { + let gitmodules_path = workdir.join(".gitmodules"); + std::fs::read_to_string(gitmodules_path).unwrap_or_default() +} + +fn enrich_from_gitmodules(submodules: &mut [SubmoduleInfo], gitmodules: &str) { + let sections = parse_gitmodules(gitmodules); + for sub in submodules.iter_mut() { + if let Some(section) = sections.iter().find(|s| s.path == sub.path) { + sub.url.clone_from(§ion.url); + sub.branch.clone_from(§ion.branch); + } + } +} + +struct GitmoduleSection { + path: String, + url: String, + branch: Option, +} + +fn parse_gitmodules(content: &str) -> Vec { + let mut sections = Vec::new(); + let mut current_path = String::new(); + let mut current_url = String::new(); + let mut current_branch: Option = None; + let mut in_section = false; + + for line in content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("[submodule ") { + if in_section && !current_path.is_empty() { + sections.push(GitmoduleSection { + path: current_path.clone(), + url: current_url.clone(), + branch: current_branch.clone(), + }); + } + current_path.clear(); + current_url.clear(); + current_branch = None; + in_section = true; + } else if in_section { + if let Some(val) = trimmed.strip_prefix("path = ") { + current_path = val.to_string(); + } else if let Some(val) = trimmed.strip_prefix("url = ") { + current_url = val.to_string(); + } else if let Some(val) = trimmed.strip_prefix("branch = ") { + current_branch = Some(val.to_string()); + } + } + } + + if in_section && !current_path.is_empty() { + sections.push(GitmoduleSection { + path: current_path, + url: current_url, + branch: current_branch, + }); + } + + sections +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_submodule_status_up_to_date() { + let output = " abc1234567890 vendor/lib-auth (v1.0.0)\n"; + let result = parse_submodule_status(output); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "vendor/lib-auth"); + assert_eq!(result[0].status, SubmoduleStatus::UpToDate); + assert_eq!(result[0].head_oid.as_deref(), Some("abc1234567890")); + assert_eq!(result[0].head_short_oid.as_deref(), Some("abc1234")); + } + + #[test] + fn parse_submodule_status_modified() { + let output = "+def5678901234 vendor/lib-crypto (heads/v2.x)\n"; + let result = parse_submodule_status(output); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "vendor/lib-crypto"); + assert_eq!(result[0].status, SubmoduleStatus::Modified); + assert_eq!(result[0].head_oid.as_deref(), Some("def5678901234")); + } + + #[test] + fn parse_submodule_status_uninitialized() { + let output = "-aaa1111222233 vendor/lib-utils\n"; + let result = parse_submodule_status(output); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "vendor/lib-utils"); + assert_eq!(result[0].status, SubmoduleStatus::Uninitialized); + assert!(result[0].head_oid.is_none()); + assert!(result[0].head_short_oid.is_none()); + } + + #[test] + fn parse_submodule_status_conflict() { + let output = "Uabc1234567890 vendor/lib-conflict (v1.0.0)\n"; + let result = parse_submodule_status(output); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "vendor/lib-conflict"); + assert_eq!(result[0].status, SubmoduleStatus::Conflict); + assert_eq!(result[0].head_oid.as_deref(), Some("abc1234567890")); + } + + #[test] + fn parse_submodule_status_multiple() { + let output = " abc1234567890 vendor/a (v1.0)\n+def5678901234 vendor/b\n"; + let result = parse_submodule_status(output); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].path, "vendor/a"); + assert_eq!(result[1].path, "vendor/b"); + } + + #[test] + fn parse_submodule_status_empty() { + let result = parse_submodule_status(""); + assert!(result.is_empty()); + } + + #[test] + fn parse_gitmodules_single() { + let content = r#"[submodule "vendor/lib-auth"] + path = vendor/lib-auth + url = git@github.com:example/lib-auth.git + branch = main +"#; + let sections = parse_gitmodules(content); + + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].path, "vendor/lib-auth"); + assert_eq!(sections[0].url, "git@github.com:example/lib-auth.git"); + assert_eq!(sections[0].branch.as_deref(), Some("main")); + } + + #[test] + fn parse_gitmodules_multiple() { + let content = r#"[submodule "vendor/a"] + path = vendor/a + url = https://example.com/a.git +[submodule "vendor/b"] + path = vendor/b + url = https://example.com/b.git + branch = develop +"#; + let sections = parse_gitmodules(content); + + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].path, "vendor/a"); + assert!(sections[0].branch.is_none()); + assert_eq!(sections[1].path, "vendor/b"); + assert_eq!(sections[1].branch.as_deref(), Some("develop")); + } + + #[test] + fn parse_gitmodules_empty() { + let sections = parse_gitmodules(""); + assert!(sections.is_empty()); + } + + #[test] + fn enrich_from_gitmodules_fills_url_and_branch() { + let mut submodules = vec![SubmoduleInfo { + path: "vendor/lib-auth".to_string(), + url: String::new(), + branch: None, + head_oid: Some("abc123".to_string()), + head_short_oid: Some("abc123".to_string()), + status: SubmoduleStatus::UpToDate, + }]; + + let gitmodules = r#"[submodule "vendor/lib-auth"] + path = vendor/lib-auth + url = git@github.com:example/lib-auth.git + branch = main +"#; + + enrich_from_gitmodules(&mut submodules, gitmodules); + + assert_eq!(submodules[0].url, "git@github.com:example/lib-auth.git"); + assert_eq!(submodules[0].branch.as_deref(), Some("main")); + } +} diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index e190edd..979c46d 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -438,3 +438,36 @@ pub struct RevertResult { pub conflicts: Vec, pub oid: Option, } + +// === Submodule types === + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubmoduleStatus { + UpToDate, + Modified, + Uninitialized, + Conflict, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmoduleInfo { + pub path: String, + pub url: String, + pub branch: Option, + pub head_oid: Option, + pub head_short_oid: Option, + pub status: SubmoduleStatus, +} + +// === Worktree types === + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorktreeInfo { + pub path: String, + pub branch: Option, + pub head_oid: Option, + pub head_short_oid: Option, + pub is_main: bool, + pub is_clean: bool, +} diff --git a/src-tauri/src/git/worktree.rs b/src-tauri/src/git/worktree.rs new file mode 100644 index 0000000..db8be22 --- /dev/null +++ b/src-tauri/src/git/worktree.rs @@ -0,0 +1,235 @@ +use std::path::Path; +use std::process::Command; + +use crate::git::error::{GitError, GitResult}; +use crate::git::types::WorktreeInfo; + +pub fn list_worktrees(workdir: &Path) -> GitResult> { + let output = Command::new("git") + .current_dir(workdir) + .args(["worktree", "list", "--porcelain"]) + .output() + .map_err(|e| GitError::WorktreeFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::WorktreeFailed(stderr.to_string().into())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut worktrees = parse_worktree_list(&stdout); + + for wt in &mut worktrees { + wt.is_clean = check_worktree_clean(&wt.path); + } + + Ok(worktrees) +} + +pub fn add_worktree(workdir: &Path, path: &str, branch: &str) -> GitResult<()> { + let output = Command::new("git") + .current_dir(workdir) + .args(["worktree", "add", path, branch]) + .output() + .map_err(|e| GitError::WorktreeFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::WorktreeFailed(stderr.to_string().into())); + } + + Ok(()) +} + +pub fn remove_worktree(workdir: &Path, path: &str) -> GitResult<()> { + let output = Command::new("git") + .current_dir(workdir) + .args(["worktree", "remove", path]) + .output() + .map_err(|e| GitError::WorktreeFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::WorktreeFailed(stderr.to_string().into())); + } + + Ok(()) +} + +fn check_worktree_clean(worktree_path: &str) -> bool { + let output = Command::new("git") + .args(["-C", worktree_path, "status", "--porcelain"]) + .output(); + + match output { + Ok(o) if o.status.success() => { + String::from_utf8_lossy(&o.stdout).trim().is_empty() + } + _ => false, + } +} + +/// Parse `git worktree list --porcelain` output. +/// Each worktree block is separated by a blank line. +/// Fields: worktree , HEAD , branch refs/heads/, bare/detached +fn parse_worktree_list(output: &str) -> Vec { + let mut worktrees = Vec::new(); + let mut path = String::new(); + let mut head_oid: Option = None; + let mut branch: Option = None; + let mut is_bare = false; + + for line in output.lines() { + if line.is_empty() { + if !path.is_empty() { + let is_main = worktrees.is_empty(); + let head_short_oid = head_oid + .as_ref() + .map(|oid| if oid.len() >= 7 { &oid[..7] } else { oid }) + .map(|s| s.to_string()); + + if !is_bare { + worktrees.push(WorktreeInfo { + path: path.clone(), + branch: branch.clone(), + head_oid: head_oid.clone(), + head_short_oid, + is_main, + is_clean: true, + }); + } + + path.clear(); + head_oid = None; + branch = None; + is_bare = false; + } + continue; + } + + if let Some(val) = line.strip_prefix("worktree ") { + path = val.to_string(); + } else if let Some(val) = line.strip_prefix("HEAD ") { + head_oid = Some(val.to_string()); + } else if let Some(val) = line.strip_prefix("branch ") { + branch = Some(strip_refs_prefix(val).to_string()); + } else if line == "bare" { + is_bare = true; + } + } + + // Handle last block without trailing newline + if !path.is_empty() && !is_bare { + let is_main = worktrees.is_empty(); + let head_short_oid = head_oid + .as_ref() + .map(|oid| if oid.len() >= 7 { &oid[..7] } else { oid }) + .map(|s| s.to_string()); + + worktrees.push(WorktreeInfo { + path, + branch, + head_oid, + head_short_oid, + is_main, + is_clean: true, + }); + } + + worktrees +} + +fn strip_refs_prefix(refname: &str) -> &str { + refname + .strip_prefix("refs/heads/") + .unwrap_or(refname) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_worktree_list_single_main() { + let output = "worktree /Users/dev/rocket\nHEAD abc1234567890\nbranch refs/heads/main\n\n"; + let result = parse_worktree_list(output); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "/Users/dev/rocket"); + assert_eq!(result[0].branch.as_deref(), Some("main")); + assert_eq!(result[0].head_oid.as_deref(), Some("abc1234567890")); + assert_eq!(result[0].head_short_oid.as_deref(), Some("abc1234")); + assert!(result[0].is_main); + assert!(result[0].is_clean); + } + + #[test] + fn parse_worktree_list_multiple() { + let output = "\ +worktree /Users/dev/rocket +HEAD abc1234567890 +branch refs/heads/main + +worktree /Users/dev/rocket-feature +HEAD def5678901234 +branch refs/heads/feature/auth + +"; + let result = parse_worktree_list(output); + + assert_eq!(result.len(), 2); + assert!(result[0].is_main); + assert!(!result[1].is_main); + assert_eq!(result[1].path, "/Users/dev/rocket-feature"); + assert_eq!(result[1].branch.as_deref(), Some("feature/auth")); + } + + #[test] + fn parse_worktree_list_detached_head() { + let output = "worktree /Users/dev/rocket\nHEAD abc1234567890\nbranch refs/heads/main\n\nworktree /Users/dev/rocket-detached\nHEAD fff0000111122\ndetached\n\n"; + let result = parse_worktree_list(output); + + assert_eq!(result.len(), 2); + assert!(result[1].branch.is_none()); + } + + #[test] + fn parse_worktree_list_bare_skipped() { + let output = "worktree /Users/dev/rocket.git\nbare\n\nworktree /Users/dev/rocket-wt\nHEAD abc1234567890\nbranch refs/heads/main\n\n"; + let result = parse_worktree_list(output); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "/Users/dev/rocket-wt"); + assert!(result[0].is_main); + } + + #[test] + fn parse_worktree_list_empty() { + let result = parse_worktree_list(""); + assert!(result.is_empty()); + } + + #[test] + fn parse_worktree_list_no_trailing_newline() { + let output = "worktree /Users/dev/rocket\nHEAD abc1234567890\nbranch refs/heads/main"; + let result = parse_worktree_list(output); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "/Users/dev/rocket"); + } + + #[test] + fn strip_refs_prefix_removes_heads() { + assert_eq!(strip_refs_prefix("refs/heads/main"), "main"); + } + + #[test] + fn strip_refs_prefix_preserves_other() { + assert_eq!(strip_refs_prefix("refs/tags/v1.0"), "refs/tags/v1.0"); + } + + #[test] + fn strip_refs_prefix_preserves_plain() { + assert_eq!(strip_refs_prefix("main"), "main"); + } +} From 7694cee0c3290b1a9be1aae194911267981c4e87 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 28 Feb 2026 12:42:32 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(commands):=20=E3=82=B5=E3=83=96?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=83=BB=E3=83=AF?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E3=83=84=E3=83=AA=E3=83=BC=E3=81=AETauri?= =?UTF-8?q?=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/submodule.rs | 60 +++++++++++++++++++++++++++++ src-tauri/src/commands/worktree.rs | 42 ++++++++++++++++++++ src-tauri/src/lib.rs | 8 ++++ 4 files changed, 112 insertions(+) create mode 100644 src-tauri/src/commands/submodule.rs create mode 100644 src-tauri/src/commands/worktree.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9295939..6bd6d9e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -12,4 +12,6 @@ pub mod reset; pub mod revert; pub mod search; pub mod stash; +pub mod submodule; pub mod tag; +pub mod worktree; diff --git a/src-tauri/src/commands/submodule.rs b/src-tauri/src/commands/submodule.rs new file mode 100644 index 0000000..4296e17 --- /dev/null +++ b/src-tauri/src/commands/submodule.rs @@ -0,0 +1,60 @@ +use tauri::State; + +use crate::git::types::SubmoduleInfo; +use crate::state::AppState; + +#[tauri::command] +pub fn list_submodules(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.list_submodules().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_submodule(url: String, path: 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 + .add_submodule(&url, &path) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_submodule(path: 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 + .update_submodule(&path) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_all_submodules(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.update_all_submodules().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_submodule(path: 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 + .remove_submodule(&path) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/worktree.rs b/src-tauri/src/commands/worktree.rs new file mode 100644 index 0000000..e6c0783 --- /dev/null +++ b/src-tauri/src/commands/worktree.rs @@ -0,0 +1,42 @@ +use tauri::State; + +use crate::git::types::WorktreeInfo; +use crate::state::AppState; + +#[tauri::command] +pub fn list_worktrees(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.list_worktrees().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_worktree( + path: String, + branch: 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 + .add_worktree(&path, &branch) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_worktree(path: 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 + .remove_worktree(&path) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 755cc6c..843e4a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -173,6 +173,14 @@ pub fn run() { commands::search::search_code, commands::search::search_commits, commands::search::search_filenames, + commands::submodule::list_submodules, + commands::submodule::add_submodule, + commands::submodule::update_submodule, + commands::submodule::update_all_submodules, + commands::submodule::remove_submodule, + commands::worktree::list_worktrees, + commands::worktree::add_worktree, + commands::worktree::remove_worktree, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); From 8760a7457e39c41711ba3c0ebd997ed0a58757b1 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 28 Feb 2026 12:42:42 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(frontend):=20=E3=82=B5=E3=83=96?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=83=BB=E3=83=AF?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E3=83=84=E3=83=AA=E3=83=BC=E3=81=AEIPC?= =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/services/submodule.ts | 36 ++++++++++++++++++++++++++++++++++++ src/services/worktree.ts | 22 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/services/submodule.ts create mode 100644 src/services/worktree.ts diff --git a/src/services/submodule.ts b/src/services/submodule.ts new file mode 100644 index 0000000..c1d5d93 --- /dev/null +++ b/src/services/submodule.ts @@ -0,0 +1,36 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type SubmoduleStatus = + | "up_to_date" + | "modified" + | "uninitialized" + | "conflict"; + +export interface SubmoduleInfo { + path: string; + url: string; + branch: string | null; + head_oid: string | null; + head_short_oid: string | null; + status: SubmoduleStatus; +} + +export function listSubmodules(): Promise { + return invoke("list_submodules"); +} + +export function addSubmodule(url: string, path: string): Promise { + return invoke("add_submodule", { url, path }); +} + +export function updateSubmodule(path: string): Promise { + return invoke("update_submodule", { path }); +} + +export function updateAllSubmodules(): Promise { + return invoke("update_all_submodules"); +} + +export function removeSubmodule(path: string): Promise { + return invoke("remove_submodule", { path }); +} diff --git a/src/services/worktree.ts b/src/services/worktree.ts new file mode 100644 index 0000000..df53152 --- /dev/null +++ b/src/services/worktree.ts @@ -0,0 +1,22 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface WorktreeInfo { + path: string; + branch: string | null; + head_oid: string | null; + head_short_oid: string | null; + is_main: boolean; + is_clean: boolean; +} + +export function listWorktrees(): Promise { + return invoke("list_worktrees"); +} + +export function addWorktree(path: string, branch: string): Promise { + return invoke("add_worktree", { path, branch }); +} + +export function removeWorktree(path: string): Promise { + return invoke("remove_worktree", { path }); +} From 136bfbab6708fc1c8be467b1329ac4974dc46553 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 28 Feb 2026 12:42:50 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(frontend):=20=E3=82=B5=E3=83=96?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=83=BB=E3=83=AF?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E3=83=84=E3=83=AA=E3=83=BC=E3=81=AE=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8UI=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/main.tsx | 2 + src/pages/submodules/index.tsx | 142 +++++++++++++ .../submodules/molecules/SubmoduleCard.tsx | 101 +++++++++ .../organisms/AddSubmoduleDialog.tsx | 74 +++++++ .../submodules/organisms/SubmoduleList.tsx | 49 +++++ src/pages/worktrees/index.tsx | 112 ++++++++++ .../worktrees/molecules/WorktreeCard.tsx | 76 +++++++ .../worktrees/organisms/AddWorktreeDialog.tsx | 68 ++++++ .../worktrees/organisms/WorktreeList.tsx | 32 +++ src/stores/__tests__/gitStore.test.ts | 200 ++++++++++++++++++ src/stores/gitStore.ts | 100 +++++++++ src/stores/uiStore.ts | 4 +- src/styles/submodules.css | 92 ++++++++ src/styles/worktrees.css | 127 +++++++++++ 14 files changed, 1178 insertions(+), 1 deletion(-) create mode 100644 src/pages/submodules/index.tsx create mode 100644 src/pages/submodules/molecules/SubmoduleCard.tsx create mode 100644 src/pages/submodules/organisms/AddSubmoduleDialog.tsx create mode 100644 src/pages/submodules/organisms/SubmoduleList.tsx create mode 100644 src/pages/worktrees/index.tsx create mode 100644 src/pages/worktrees/molecules/WorktreeCard.tsx create mode 100644 src/pages/worktrees/organisms/AddWorktreeDialog.tsx create mode 100644 src/pages/worktrees/organisms/WorktreeList.tsx create mode 100644 src/styles/submodules.css create mode 100644 src/styles/worktrees.css diff --git a/src/main.tsx b/src/main.tsx index a4133bd..7dcb628 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -23,6 +23,8 @@ import "./styles/revert.css"; import "./styles/reset.css"; import "./styles/reflog.css"; import "./styles/search.css"; +import "./styles/submodules.css"; +import "./styles/worktrees.css"; const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/src/pages/submodules/index.tsx b/src/pages/submodules/index.tsx new file mode 100644 index 0000000..9dae597 --- /dev/null +++ b/src/pages/submodules/index.tsx @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; +import { Modal } from "../../components/organisms/Modal"; +import { useGitStore } from "../../stores/gitStore"; +import { useUIStore } from "../../stores/uiStore"; +import { AddSubmoduleDialog } from "./organisms/AddSubmoduleDialog"; +import { SubmoduleList } from "./organisms/SubmoduleList"; + +export function SubmodulesPage() { + const submodules = useGitStore((s) => s.submodules); + const fetchSubmodules = useGitStore((s) => s.fetchSubmodules); + const addSubmodule = useGitStore((s) => s.addSubmodule); + const updateSubmodule = useGitStore((s) => s.updateSubmodule); + const updateAllSubmodules = useGitStore((s) => s.updateAllSubmodules); + const removeSubmodule = useGitStore((s) => s.removeSubmodule); + const addToast = useUIStore((s) => s.addToast); + const activeModal = useUIStore((s) => s.activeModal); + const openModal = useUIStore((s) => s.openModal); + const closeModal = useUIStore((s) => s.closeModal); + + const [confirmRemove, setConfirmRemove] = useState(null); + + useEffect(() => { + fetchSubmodules().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [fetchSubmodules, addToast]); + + const handleAdd = useCallback( + async (url: string, path: string) => { + try { + await addSubmodule(url, path); + addToast("Submodule added", "success"); + closeModal(); + await fetchSubmodules(); + } catch (e: unknown) { + addToast(`Add submodule failed: ${String(e)}`, "error"); + } + }, + [addSubmodule, addToast, closeModal, fetchSubmodules], + ); + + const handleUpdate = useCallback( + async (path: string) => { + try { + await updateSubmodule(path); + addToast(`Submodule '${path}' updated`, "success"); + await fetchSubmodules(); + } catch (e: unknown) { + addToast(`Update failed: ${String(e)}`, "error"); + } + }, + [updateSubmodule, addToast, fetchSubmodules], + ); + + const handleUpdateAll = useCallback(async () => { + try { + await updateAllSubmodules(); + addToast("All submodules updated", "success"); + await fetchSubmodules(); + } catch (e: unknown) { + addToast(`Update all failed: ${String(e)}`, "error"); + } + }, [updateAllSubmodules, addToast, fetchSubmodules]); + + const handleRemoveConfirm = useCallback( + async (path: string) => { + try { + await removeSubmodule(path); + addToast(`Submodule '${path}' removed`, "success"); + setConfirmRemove(null); + await fetchSubmodules(); + } catch (e: unknown) { + addToast(`Remove failed: ${String(e)}`, "error"); + } + }, + [removeSubmodule, addToast, fetchSubmodules], + ); + + return ( +
+
+
+

Submodules

+ + Add, update, and remove submodules + +
+
+ +
+
+ + setConfirmRemove(path)} + /> + + {activeModal === "add-submodule" && ( + + )} + + {confirmRemove && ( + setConfirmRemove(null)} + footer={ + <> + + + + } + > +

+ Remove submodule {confirmRemove}? This will + deinitialize and remove it from the repository. +

+
+ )} +
+ ); +} diff --git a/src/pages/submodules/molecules/SubmoduleCard.tsx b/src/pages/submodules/molecules/SubmoduleCard.tsx new file mode 100644 index 0000000..3d05453 --- /dev/null +++ b/src/pages/submodules/molecules/SubmoduleCard.tsx @@ -0,0 +1,101 @@ +import type { SubmoduleInfo } from "../../../services/submodule"; + +interface SubmoduleCardProps { + submodule: SubmoduleInfo; + onUpdate: (path: string) => void; + onRemove: (path: string) => void; +} + +function statusLabel(status: SubmoduleInfo["status"]): string { + switch (status) { + case "up_to_date": + return "\u2713 Up to date"; + case "modified": + return "\u26A0 Updates available"; + case "uninitialized": + return "Uninitialized"; + case "conflict": + return "\u2716 Conflict"; + } +} + +function statusClass(status: SubmoduleInfo["status"]): string { + switch (status) { + case "up_to_date": + return "up-to-date"; + case "modified": + return "behind"; + case "uninitialized": + return "uninitialized"; + case "conflict": + return "conflict"; + } +} + +export function SubmoduleCard({ + submodule, + onUpdate, + onRemove, +}: SubmoduleCardProps) { + return ( +
+
+
+ +
+
+
{submodule.path}
+
{submodule.url}
+
+
+ {statusLabel(submodule.status)} +
+
+
+ {submodule.branch && ( +
+ Branch: + {submodule.branch} +
+ )} + {submodule.head_short_oid && ( +
+ Commit: + + {submodule.head_short_oid} + +
+ )} +
+
+ + + +
+
+ ); +} diff --git a/src/pages/submodules/organisms/AddSubmoduleDialog.tsx b/src/pages/submodules/organisms/AddSubmoduleDialog.tsx new file mode 100644 index 0000000..71fd4e5 --- /dev/null +++ b/src/pages/submodules/organisms/AddSubmoduleDialog.tsx @@ -0,0 +1,74 @@ +import { useCallback, useId, useState } from "react"; +import { Modal } from "../../../components/organisms/Modal"; + +interface AddSubmoduleDialogProps { + onConfirm: (url: string, path: string) => void; + onClose: () => void; +} + +export function AddSubmoduleDialog({ + onConfirm, + onClose, +}: AddSubmoduleDialogProps) { + const [url, setUrl] = useState(""); + const [path, setPath] = useState(""); + const urlId = useId(); + const pathId = useId(); + + const handleSubmit = useCallback(() => { + if (!url.trim()) return; + const resolvedPath = path.trim() || derivePathFromUrl(url.trim()); + onConfirm(url.trim(), resolvedPath); + }, [url, path, onConfirm]); + + return ( + + + + + } + > + + setUrl(e.target.value)} + placeholder="https://github.com/example/repo.git" + /> + + setPath(e.target.value)} + placeholder="Derived from URL if empty" + /> + + ); +} + +function derivePathFromUrl(url: string): string { + const lastSegment = url.split("/").pop() ?? url; + return lastSegment.replace(/\.git$/, ""); +} diff --git a/src/pages/submodules/organisms/SubmoduleList.tsx b/src/pages/submodules/organisms/SubmoduleList.tsx new file mode 100644 index 0000000..90e8c95 --- /dev/null +++ b/src/pages/submodules/organisms/SubmoduleList.tsx @@ -0,0 +1,49 @@ +import type { SubmoduleInfo } from "../../../services/submodule"; +import { SubmoduleCard } from "../molecules/SubmoduleCard"; + +interface SubmoduleListProps { + submodules: SubmoduleInfo[]; + onUpdate: (path: string) => void; + onUpdateAll: () => void; + onRemove: (path: string) => void; +} + +export function SubmoduleList({ + submodules, + onUpdate, + onUpdateAll, + onRemove, +}: SubmoduleListProps) { + return ( +
+ {submodules.length > 0 && ( +
+ +
+ )} +
+ {submodules.length === 0 && ( +
No submodules configured
+ )} + {submodules.map((sub) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/worktrees/index.tsx b/src/pages/worktrees/index.tsx new file mode 100644 index 0000000..122b1ab --- /dev/null +++ b/src/pages/worktrees/index.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from "react"; +import { Modal } from "../../components/organisms/Modal"; +import { useGitStore } from "../../stores/gitStore"; +import { useUIStore } from "../../stores/uiStore"; +import { AddWorktreeDialog } from "./organisms/AddWorktreeDialog"; +import { WorktreeList } from "./organisms/WorktreeList"; + +export function WorktreesPage() { + const worktrees = useGitStore((s) => s.worktrees); + const fetchWorktrees = useGitStore((s) => s.fetchWorktrees); + const addWorktree = useGitStore((s) => s.addWorktree); + const removeWorktree = useGitStore((s) => s.removeWorktree); + const addToast = useUIStore((s) => s.addToast); + const activeModal = useUIStore((s) => s.activeModal); + const openModal = useUIStore((s) => s.openModal); + const closeModal = useUIStore((s) => s.closeModal); + + const [confirmRemove, setConfirmRemove] = useState(null); + + useEffect(() => { + fetchWorktrees().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [fetchWorktrees, addToast]); + + const handleAdd = useCallback( + async (path: string, branch: string) => { + try { + await addWorktree(path, branch); + addToast("Worktree added", "success"); + closeModal(); + await fetchWorktrees(); + } catch (e: unknown) { + addToast(`Add worktree failed: ${String(e)}`, "error"); + } + }, + [addWorktree, addToast, closeModal, fetchWorktrees], + ); + + const handleRemoveConfirm = useCallback( + async (path: string) => { + try { + await removeWorktree(path); + addToast(`Worktree '${path}' removed`, "success"); + setConfirmRemove(null); + await fetchWorktrees(); + } catch (e: unknown) { + addToast(`Remove failed: ${String(e)}`, "error"); + } + }, + [removeWorktree, addToast, fetchWorktrees], + ); + + return ( +
+
+
+

Worktrees

+ Manage multiple worktrees +
+
+ +
+
+ + setConfirmRemove(path)} + /> + + {activeModal === "add-worktree" && ( + + )} + + {confirmRemove && ( + setConfirmRemove(null)} + footer={ + <> + + + + } + > +

+ Remove worktree at {confirmRemove}? +

+
+ )} +
+ ); +} diff --git a/src/pages/worktrees/molecules/WorktreeCard.tsx b/src/pages/worktrees/molecules/WorktreeCard.tsx new file mode 100644 index 0000000..19b13b9 --- /dev/null +++ b/src/pages/worktrees/molecules/WorktreeCard.tsx @@ -0,0 +1,76 @@ +import type { WorktreeInfo } from "../../../services/worktree"; + +interface WorktreeCardProps { + worktree: WorktreeInfo; + onRemove: (path: string) => void; +} + +export function WorktreeCard({ worktree, onRemove }: WorktreeCardProps) { + return ( +
+
+
+ {worktree.is_main ? ( + + ) : ( + + )} +
+
+
{worktree.path}
+ {worktree.branch && ( +
+ + {worktree.branch} +
+ )} +
+
+ {worktree.is_main && ( + Main + )} + + {worktree.is_clean ? "\u2713 Clean" : "\u25CF Modified"} + +
+
+ {worktree.head_short_oid && ( +
+
+ Commit: + {worktree.head_short_oid} +
+
+ )} + {!worktree.is_main && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/pages/worktrees/organisms/AddWorktreeDialog.tsx b/src/pages/worktrees/organisms/AddWorktreeDialog.tsx new file mode 100644 index 0000000..6cd3586 --- /dev/null +++ b/src/pages/worktrees/organisms/AddWorktreeDialog.tsx @@ -0,0 +1,68 @@ +import { useCallback, useId, useState } from "react"; +import { Modal } from "../../../components/organisms/Modal"; + +interface AddWorktreeDialogProps { + onConfirm: (path: string, branch: string) => void; + onClose: () => void; +} + +export function AddWorktreeDialog({ + onConfirm, + onClose, +}: AddWorktreeDialogProps) { + const [path, setPath] = useState(""); + const [branch, setBranch] = useState(""); + const pathId = useId(); + const branchId = useId(); + + const handleSubmit = useCallback(() => { + if (!path.trim() || !branch.trim()) return; + onConfirm(path.trim(), branch.trim()); + }, [path, branch, onConfirm]); + + return ( + + + + + } + > + + setPath(e.target.value)} + placeholder="/path/to/new/worktree" + /> + + setBranch(e.target.value)} + placeholder="feature/my-branch" + /> + + ); +} diff --git a/src/pages/worktrees/organisms/WorktreeList.tsx b/src/pages/worktrees/organisms/WorktreeList.tsx new file mode 100644 index 0000000..fff55e9 --- /dev/null +++ b/src/pages/worktrees/organisms/WorktreeList.tsx @@ -0,0 +1,32 @@ +import type { WorktreeInfo } from "../../../services/worktree"; +import { WorktreeCard } from "../molecules/WorktreeCard"; + +interface WorktreeListProps { + worktrees: WorktreeInfo[]; + onRemove: (path: string) => void; +} + +export function WorktreeList({ worktrees, onRemove }: WorktreeListProps) { + return ( +
+
+ + + Worktrees allow you to work on different branches of the same + repository simultaneously + +
+
+ {worktrees.length === 0 && ( +
No worktrees found
+ )} + {worktrees.map((wt) => ( + + ))} +
+
+ ); +} diff --git a/src/stores/__tests__/gitStore.test.ts b/src/stores/__tests__/gitStore.test.ts index 4df66ee..2e05ed7 100644 --- a/src/stores/__tests__/gitStore.test.ts +++ b/src/stores/__tests__/gitStore.test.ts @@ -20,6 +20,8 @@ describe("gitStore", () => { remotes: [], stashes: [], tags: [], + submodules: [], + worktrees: [], merging: false, rebasing: false, rebaseState: null, @@ -1447,6 +1449,204 @@ describe("gitStore", () => { }); }); + describe("fetchSubmodules", () => { + it("sets submodules on success", async () => { + const mockSubmodules = [ + { + path: "vendor/lib-auth", + url: "https://example.com/lib-auth.git", + branch: "main", + head_oid: "abc1234567890", + head_short_oid: "abc1234", + status: "up_to_date", + }, + ]; + mockedInvoke.mockResolvedValueOnce(mockSubmodules); + + await useGitStore.getState().fetchSubmodules(); + + expect(useGitStore.getState().submodules).toEqual(mockSubmodules); + expect(mockedInvoke).toHaveBeenCalledWith("list_submodules"); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("submodule error")); + + await expect(useGitStore.getState().fetchSubmodules()).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("submodule error"); + }); + }); + + describe("addSubmodule", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore + .getState() + .addSubmodule("https://example.com/lib.git", "vendor/lib"); + + expect(mockedInvoke).toHaveBeenCalledWith("add_submodule", { + url: "https://example.com/lib.git", + path: "vendor/lib", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("add submodule error")); + + await expect( + useGitStore.getState().addSubmodule("url", "path"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("add submodule error"); + }); + }); + + describe("updateSubmodule", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore.getState().updateSubmodule("vendor/lib"); + + expect(mockedInvoke).toHaveBeenCalledWith("update_submodule", { + path: "vendor/lib", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("update submodule error")); + + await expect( + useGitStore.getState().updateSubmodule("vendor/lib"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("update submodule error"); + }); + }); + + describe("updateAllSubmodules", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore.getState().updateAllSubmodules(); + + expect(mockedInvoke).toHaveBeenCalledWith("update_all_submodules"); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce( + new Error("update all submodules error"), + ); + + await expect( + useGitStore.getState().updateAllSubmodules(), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain( + "update all submodules error", + ); + }); + }); + + describe("removeSubmodule", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore.getState().removeSubmodule("vendor/lib"); + + expect(mockedInvoke).toHaveBeenCalledWith("remove_submodule", { + path: "vendor/lib", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("remove submodule error")); + + await expect( + useGitStore.getState().removeSubmodule("vendor/lib"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("remove submodule error"); + }); + }); + + describe("fetchWorktrees", () => { + it("sets worktrees on success", async () => { + const mockWorktrees = [ + { + path: "/Users/dev/rocket", + branch: "main", + head_oid: "abc1234567890", + head_short_oid: "abc1234", + is_main: true, + is_clean: true, + }, + ]; + mockedInvoke.mockResolvedValueOnce(mockWorktrees); + + await useGitStore.getState().fetchWorktrees(); + + expect(useGitStore.getState().worktrees).toEqual(mockWorktrees); + expect(mockedInvoke).toHaveBeenCalledWith("list_worktrees"); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("worktree error")); + + await expect(useGitStore.getState().fetchWorktrees()).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("worktree error"); + }); + }); + + describe("addWorktree", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore + .getState() + .addWorktree("/Users/dev/rocket-feature", "feature/auth"); + + expect(mockedInvoke).toHaveBeenCalledWith("add_worktree", { + path: "/Users/dev/rocket-feature", + branch: "feature/auth", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("add worktree error")); + + await expect( + useGitStore.getState().addWorktree("/path", "branch"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("add worktree error"); + }); + }); + + describe("removeWorktree", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useGitStore.getState().removeWorktree("/Users/dev/rocket-feature"); + + expect(mockedInvoke).toHaveBeenCalledWith("remove_worktree", { + path: "/Users/dev/rocket-feature", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("remove worktree error")); + + await expect( + useGitStore.getState().removeWorktree("/path"), + ).rejects.toThrow(); + + expect(useGitStore.getState().error).toContain("remove worktree 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 2e51e60..3f78ff5 100644 --- a/src/stores/gitStore.ts +++ b/src/stores/gitStore.ts @@ -93,6 +93,14 @@ import { popStash as popStashService, stashSave as stashSaveService, } from "../services/stash"; +import type { SubmoduleInfo } from "../services/submodule"; +import { + addSubmodule as addSubmoduleService, + listSubmodules, + removeSubmodule as removeSubmoduleService, + updateAllSubmodules as updateAllSubmodulesService, + updateSubmodule as updateSubmoduleService, +} from "../services/submodule"; import type { TagInfo } from "../services/tag"; import { checkoutTag as checkoutTagService, @@ -100,6 +108,12 @@ import { deleteTag as deleteTagService, listTags, } from "../services/tag"; +import type { WorktreeInfo } from "../services/worktree"; +import { + addWorktree as addWorktreeService, + listWorktrees, + removeWorktree as removeWorktreeService, +} from "../services/worktree"; const REBASE_TODO_DEFAULT_LIMIT = 100; @@ -111,6 +125,8 @@ interface GitState { remotes: RemoteInfo[]; stashes: StashEntry[]; tags: TagInfo[]; + submodules: SubmoduleInfo[]; + worktrees: WorktreeInfo[]; merging: boolean; rebasing: boolean; rebaseState: RebaseState | null; @@ -198,6 +214,14 @@ interface GitActions { continueRevert: () => Promise; resetToCommit: (oid: string, mode: ResetMode) => Promise; resetFile: (path: string, oid: string) => Promise; + fetchSubmodules: () => Promise; + addSubmodule: (url: string, path: string) => Promise; + updateSubmodule: (path: string) => Promise; + updateAllSubmodules: () => Promise; + removeSubmodule: (path: string) => Promise; + fetchWorktrees: () => Promise; + addWorktree: (path: string, branch: string) => Promise; + removeWorktree: (path: string) => Promise; clearError: () => void; } @@ -209,6 +233,8 @@ export const useGitStore = create((set) => ({ remotes: [], stashes: [], tags: [], + submodules: [], + worktrees: [], merging: false, rebasing: false, rebaseState: null, @@ -807,6 +833,80 @@ export const useGitStore = create((set) => ({ } }, + fetchSubmodules: async () => { + try { + const submodules = await listSubmodules(); + set({ submodules }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + addSubmodule: async (url: string, path: string) => { + try { + await addSubmoduleService(url, path); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + updateSubmodule: async (path: string) => { + try { + await updateSubmoduleService(path); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + updateAllSubmodules: async () => { + try { + await updateAllSubmodulesService(); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + removeSubmodule: async (path: string) => { + try { + await removeSubmoduleService(path); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + fetchWorktrees: async () => { + try { + const worktrees = await listWorktrees(); + set({ worktrees }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + addWorktree: async (path: string, branch: string) => { + try { + await addWorktreeService(path, branch); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + removeWorktree: async (path: string) => { + try { + await removeWorktreeService(path); + } 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 50c9d83..781c132 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -13,7 +13,9 @@ export type PageId = | "revert" | "reset" | "reflog" - | "hosting"; + | "hosting" + | "submodules" + | "worktrees"; interface BlameTarget { path: string; diff --git a/src/styles/submodules.css b/src/styles/submodules.css new file mode 100644 index 0000000..7e9dde2 --- /dev/null +++ b/src/styles/submodules.css @@ -0,0 +1,92 @@ +/* ===== Submodules Content ===== */ +.submodules-content { + flex: 1; + overflow-y: auto; + padding: 20px; +} +.submodules-toolbar { + display: flex; + gap: 12px; + margin-bottom: 20px; +} +.submodules-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.submodule-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; +} +.submodule-card.behind { + border-left: 3px solid var(--warning); +} +.submodule-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.submodule-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-dim); + border-radius: 8px; + color: var(--accent); +} +.submodule-icon svg { + width: 18px; + height: 18px; +} +.submodule-info { + flex: 1; +} +.submodule-path { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; +} +.submodule-url { + font-size: 12px; + color: var(--text-muted); + font-family: "JetBrains Mono", monospace; +} +.submodule-status { + font-size: 12px; + padding: 4px 10px; + border-radius: 6px; +} +.submodule-status.up-to-date { + background: var(--success-dim); + color: var(--success); +} +.submodule-status.behind { + background: var(--warning-dim); + color: var(--warning); +} +.submodule-status.conflict { + background: var(--danger-dim); + color: var(--danger); +} +.submodule-details { + display: flex; + gap: 24px; + margin-bottom: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 6px; +} +.submodule-detail { + display: flex; + gap: 8px; + font-size: 12px; +} +.submodule-actions { + display: flex; + gap: 8px; +} diff --git a/src/styles/worktrees.css b/src/styles/worktrees.css new file mode 100644 index 0000000..48a6496 --- /dev/null +++ b/src/styles/worktrees.css @@ -0,0 +1,127 @@ +/* ===== Worktrees Content ===== */ +.worktrees-content { + flex: 1; + overflow-y: auto; + padding: 20px; +} +.worktrees-hint { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: var(--accent-dim); + border-radius: 8px; + font-size: 13px; + color: var(--accent); + margin-bottom: 20px; +} +.worktrees-hint svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} +.worktrees-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ===== Worktree Card ===== */ +.worktree-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + transition: border-color 0.15s ease; +} +.worktree-card:hover { + border-color: var(--text-muted); +} +.worktree-card.main { + border-color: var(--accent); +} +.worktree-card.main:hover { + border-color: var(--accent); +} +.worktree-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.worktree-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 8px; + color: var(--text-muted); +} +.worktree-icon.main { + background: var(--accent-dim); + color: var(--accent); +} +.worktree-icon svg { + width: 18px; + height: 18px; +} +.worktree-info { + flex: 1; +} +.worktree-path { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; + font-family: "JetBrains Mono", monospace; +} +.worktree-branch { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--accent); +} +.worktree-branch svg { + width: 14px; + height: 14px; +} +.worktree-badges { + display: flex; + gap: 8px; +} +.worktree-badge { + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; +} +.worktree-badge.main { + background: var(--accent-dim); + color: var(--accent); +} +.worktree-badge.clean { + background: var(--success-dim); + color: var(--success); +} +.worktree-badge.modified { + background: var(--warning-dim); + color: var(--warning); +} +.worktree-details { + display: flex; + gap: 24px; + margin-bottom: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 6px; +} +.worktree-detail { + display: flex; + gap: 8px; + font-size: 12px; +} +.worktree-actions { + display: flex; + gap: 8px; +} From f4adf5b250e03760ab4ec51a93aa360dcc670d89 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 28 Feb 2026 12:42:57 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(routing):=20=E3=82=B5=E3=83=96?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=83=BB=E3=83=AF?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E3=83=84=E3=83=AA=E3=83=BC=E3=81=AE=E3=82=B5?= =?UTF-8?q?=E3=82=A4=E3=83=89=E3=83=90=E3=83=BC=E3=83=BB=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=86=E3=82=A3=E3=83=B3=E3=82=B0=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- designs/connect.json | 4 +++- src/App.tsx | 4 ++++ src/components/organisms/Sidebar.tsx | 35 ++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/designs/connect.json b/designs/connect.json index ad63689..fa122cd 100644 --- a/designs/connect.json +++ b/designs/connect.json @@ -8,7 +8,9 @@ "Cherry-pick": "cherry-pick", "Rebase": "rebase", "Files": "file-tree", - "GitHub": "hosting" + "GitHub": "hosting", + "Submodules": "submodules", + "Worktrees": "worktrees" } }, { diff --git a/src/App.tsx b/src/App.tsx index 74b6cc5..de6b8b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ import { ReflogPage } from "./pages/reflog"; import { ResetPage } from "./pages/reset"; import { RevertPage } from "./pages/revert"; import { StashPage } from "./pages/stash"; +import { SubmodulesPage } from "./pages/submodules"; +import { WorktreesPage } from "./pages/worktrees"; import type { PullOption } from "./services/git"; import { useConfigStore } from "./stores/configStore"; import { useGitStore } from "./stores/gitStore"; @@ -190,6 +192,8 @@ export function App() { {activePage === "revert" && } {activePage === "reset" && } {activePage === "reflog" && } + {activePage === "submodules" && } + {activePage === "worktrees" && } {activePage === "hosting" && } diff --git a/src/components/organisms/Sidebar.tsx b/src/components/organisms/Sidebar.tsx index ba9c4eb..d121bfc 100644 --- a/src/components/organisms/Sidebar.tsx +++ b/src/components/organisms/Sidebar.tsx @@ -166,6 +166,41 @@ export function Sidebar({ changesCount }: SidebarProps) { Reflog +
+
Modules
+ + +
Hosting