Skip to main content

twin_cli/
git.rs

1#![allow(dead_code)]
2/// Git操作モジュール
3///
4/// このモジュールの役割:
5/// - git worktree add/remove/list コマンドのラッパー
6/// - ブランチの作成と管理
7/// - 自動コミット機能の実装
8/// - Gitリポジトリの状態確認
9use crate::core::{TwinError, TwinResult};
10use chrono::{DateTime, Local};
11use log::{debug, info, warn};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14use std::process::{Command, Output};
15
16/// Worktreeの情報を表す構造体
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorktreeInfo {
19    /// Worktreeのパス
20    pub path: PathBuf,
21    /// チェックアウトされているブランチ名
22    pub branch: String,
23    /// コミットハッシュ
24    pub commit: String,
25    /// エージェント名(ブランチ名から抽出)
26    pub agent_name: Option<String>,
27    /// 作成日時
28    pub created_at: Option<DateTime<Local>>,
29    /// 最終更新日時
30    pub last_updated: Option<DateTime<Local>>,
31    /// ロック状態
32    pub locked: bool,
33    /// プルーニング可能かどうか
34    pub prunable: bool,
35}
36
37/// ブランチの情報を表す構造体
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct BranchInfo {
40    /// ブランチ名
41    pub name: String,
42    /// リモートブランチ名
43    pub remote: Option<String>,
44    /// 現在のブランチかどうか
45    pub current: bool,
46    /// コミットハッシュ
47    pub commit: String,
48    /// 上流ブランチとの差分
49    pub ahead: usize,
50    pub behind: usize,
51}
52
53/// Git操作を管理する構造体
54pub struct GitManager {
55    /// リポジトリのルートパス
56    repo_path: PathBuf,
57    /// git2ライブラリのリポジトリインスタンス(オプション)
58    repository: Option<git2::Repository>,
59    /// 実行履歴の記録
60    command_history: Vec<String>,
61    /// ドライラン モード
62    dry_run: bool,
63}
64
65impl GitManager {
66    /// 新しいGitManagerインスタンスを作成
67    pub fn new(repo_path: &Path) -> TwinResult<Self> {
68        let repo_path = repo_path.to_path_buf();
69
70        // git2ライブラリを使用してリポジトリを開く
71        let repository = match git2::Repository::open(&repo_path) {
72            Ok(repo) => {
73                info!("Opened Git repository at: {repo_path:?}");
74                Some(repo)
75            }
76            Err(e) => {
77                warn!("Failed to open repository with git2: {e}");
78                // git2で開けない場合でも、gitコマンドは使える可能性があるので続行
79                None
80            }
81        };
82
83        // gitコマンドが使用可能か確認
84        Self::verify_git_available()?;
85
86        Ok(Self {
87            repo_path,
88            repository,
89            command_history: Vec::new(),
90            dry_run: false,
91        })
92    }
93
94    /// ドライランモードを設定
95    pub fn set_dry_run(&mut self, dry_run: bool) {
96        self.dry_run = dry_run;
97    }
98
99    /// gitコマンドが使用可能か確認
100    fn verify_git_available() -> TwinResult<()> {
101        let output = Command::new("git")
102            .arg("--version")
103            .output()
104            .map_err(|e| TwinError::git(format!("Git command not found: {e}")))?;
105
106        if !output.status.success() {
107            return Err(TwinError::git("Git command failed to execute"));
108        }
109
110        Ok(())
111    }
112
113    /// gitコマンドを実行する共通メソッド
114    fn execute_git_command(&mut self, args: &[&str]) -> TwinResult<Output> {
115        let command_str = format!("git {}", args.join(" "));
116        info!("Executing: {command_str}");
117        self.command_history.push(command_str.clone());
118
119        // 透明性のあるコマンド実行ログ
120        if std::env::var("TWIN_VERBOSE").is_ok() || std::env::var("TWIN_DEBUG").is_ok() {
121            eprintln!("🔧 実行中: {command_str}");
122        }
123
124        if self.dry_run {
125            info!("[DRY RUN] Would execute: {command_str}");
126            if std::env::var("TWIN_VERBOSE").is_ok() || std::env::var("TWIN_DEBUG").is_ok() {
127                eprintln!("📝 ドライラン: {command_str}");
128            }
129            return Ok(Output {
130                #[cfg(unix)]
131                status: std::os::unix::process::ExitStatusExt::from_raw(0),
132                #[cfg(windows)]
133                status: std::os::windows::process::ExitStatusExt::from_raw(0),
134                stdout: b"[DRY RUN]".to_vec(),
135                stderr: Vec::new(),
136            });
137        }
138
139        let output = Command::new("git")
140            .current_dir(&self.repo_path)
141            .args(args)
142            .output()
143            .map_err(|e| TwinError::git(format!("Failed to execute git command: {e}")))?;
144
145        if !output.status.success() {
146            let stderr = String::from_utf8_lossy(&output.stderr);
147            return Err(TwinError::git(format!("Git command failed: {stderr}")));
148        }
149
150        Ok(output)
151    }
152
153    /// Worktreeを追加
154    pub fn add_worktree(
155        &mut self,
156        path: &Path,
157        branch: Option<&str>,
158        create_branch: bool,
159    ) -> TwinResult<WorktreeInfo> {
160        let mut args = vec!["worktree", "add"];
161
162        // 新しいブランチを作成する場合
163        if create_branch {
164            if let Some(b) = branch {
165                args.push("-b");
166                args.push(b);
167            }
168        }
169
170        // パスを追加
171        let path_str = path.to_string_lossy();
172        args.push(&path_str);
173
174        // 既存のブランチを指定する場合
175        if !create_branch {
176            if let Some(b) = branch {
177                args.push(b);
178            }
179        }
180
181        let output = self.execute_git_command(&args)?;
182        debug!(
183            "Worktree added: {:?}",
184            String::from_utf8_lossy(&output.stdout)
185        );
186
187        // 作成されたWorktreeの情報を取得
188        self.get_worktree_info(path)
189    }
190
191    /// Worktreeを追加(オプションを直接渡す)
192    pub fn add_worktree_with_options(&mut self, args: &[&str]) -> TwinResult<Output> {
193        let mut full_args = vec!["worktree", "add"];
194        full_args.extend_from_slice(args);
195
196        self.execute_git_command_raw(&full_args)
197    }
198
199    /// Gitコマンドを実行し、エラーメッセージをそのまま表示
200    pub fn execute_git_command_raw(&mut self, args: &[&str]) -> TwinResult<Output> {
201        let command_str = format!("git {}", args.join(" "));
202        info!("Executing: {command_str}");
203
204        if self.dry_run {
205            info!("[DRY RUN] Would execute: {command_str}");
206            return Ok(Output {
207                #[cfg(unix)]
208                status: std::os::unix::process::ExitStatusExt::from_raw(0),
209                #[cfg(windows)]
210                status: std::os::windows::process::ExitStatusExt::from_raw(0),
211                stdout: b"[DRY RUN]".to_vec(),
212                stderr: Vec::new(),
213            });
214        }
215
216        let output = Command::new("git")
217            .args(args)
218            .current_dir(&self.repo_path)
219            .output()
220            .map_err(|e| TwinError::git(format!("{e}")))?;
221
222        if !output.status.success() {
223            let stderr = String::from_utf8_lossy(&output.stderr);
224            // git worktreeのエラーメッセージをそのまま返す
225            return Err(TwinError::git(stderr.trim().to_string()));
226        }
227
228        Ok(output)
229    }
230
231    /// Worktreeを削除
232    pub fn remove_worktree(&mut self, path: &Path, force: bool) -> TwinResult<()> {
233        let mut args = vec!["worktree", "remove"];
234
235        if force {
236            args.push("--force");
237        }
238
239        let path_str = path.to_string_lossy();
240        args.push(&path_str);
241
242        self.execute_git_command(&args)?;
243        info!("Worktree removed: {path:?}");
244
245        Ok(())
246    }
247
248    /// Worktreeの一覧を取得
249    pub fn list_worktrees(&mut self) -> TwinResult<Vec<WorktreeInfo>> {
250        let output = self.execute_git_command(&["worktree", "list", "--porcelain"])?;
251        let stdout = String::from_utf8_lossy(&output.stdout);
252
253        self.parse_worktree_list(&stdout)
254    }
255
256    /// Worktreeリストの出力をパース
257    fn parse_worktree_list(&self, output: &str) -> TwinResult<Vec<WorktreeInfo>> {
258        let mut worktrees = Vec::new();
259        let mut current_worktree: Option<WorktreeInfo> = None;
260
261        for line in output.lines() {
262            if line.starts_with("worktree ") {
263                // 前のworktree情報を保存
264                if let Some(wt) = current_worktree.take() {
265                    worktrees.push(wt);
266                }
267
268                // 新しいworktree情報を開始
269                let path = PathBuf::from(line.strip_prefix("worktree ").unwrap());
270                current_worktree = Some(WorktreeInfo {
271                    path,
272                    branch: String::new(),
273                    commit: String::new(),
274                    agent_name: None,
275                    created_at: None,
276                    last_updated: None,
277                    locked: false,
278                    prunable: false,
279                });
280            } else if let Some(ref mut wt) = current_worktree {
281                if line.starts_with("HEAD ") {
282                    wt.commit = line.strip_prefix("HEAD ").unwrap().to_string();
283                } else if line.starts_with("branch ") {
284                    wt.branch = line.strip_prefix("branch ").unwrap().to_string();
285                    // エージェント名を抽出(例: agent/claude -> claude)
286                    if wt.branch.starts_with("agent/") {
287                        wt.agent_name = Some(wt.branch[6..].to_string());
288                    }
289                } else if line == "locked" {
290                    wt.locked = true;
291                } else if line == "prunable" {
292                    wt.prunable = true;
293                }
294            }
295        }
296
297        // 最後のworktree情報を保存
298        if let Some(wt) = current_worktree {
299            worktrees.push(wt);
300        }
301
302        Ok(worktrees)
303    }
304
305    /// 特定のWorktreeの情報を取得
306    pub fn get_worktree_info(&mut self, path: &Path) -> TwinResult<WorktreeInfo> {
307        let worktrees = self.list_worktrees()?;
308
309        // パスを絶対パスに変換して比較
310        let abs_path = if path.is_absolute() {
311            path.to_path_buf()
312        } else {
313            std::env::current_dir()
314                .map_err(|e| TwinError::io(format!("Failed to get current dir: {e}"), None))?
315                .join(path)
316        };
317
318        // 正規化されたパスでも検索を試みる
319        let canonical_path = abs_path.canonicalize().ok();
320
321        worktrees
322            .into_iter()
323            .find(|wt| {
324                // 直接比較
325                wt.path == path ||
326                wt.path == abs_path ||
327                // 正規化されたパスとの比較
328                canonical_path.as_ref().is_some_and(|cp| {
329                    wt.path.canonicalize().ok().is_some_and(|wtp| wtp == *cp)
330                }) ||
331                // ファイル名だけでも一致を確認(最後の手段)
332                wt.path.file_name() == path.file_name() && path.file_name().is_some()
333            })
334            .ok_or_else(|| TwinError::not_found("Worktree", path.to_string_lossy().to_string()))
335    }
336
337    /// プルーニング可能なWorktreeをクリーンアップ
338    pub fn prune_worktrees(&mut self, dry_run: bool) -> TwinResult<Vec<PathBuf>> {
339        let mut args = vec!["worktree", "prune"];
340
341        if dry_run {
342            args.push("--dry-run");
343        }
344
345        let output = self.execute_git_command(&args)?;
346        let stdout = String::from_utf8_lossy(&output.stdout);
347
348        // プルーニングされたWorktreeのパスを抽出
349        let pruned: Vec<PathBuf> = stdout
350            .lines()
351            .filter_map(|line| {
352                if line.contains("Removing worktrees") {
353                    Some(PathBuf::from(line.rsplit(":").next()?.trim()))
354                } else {
355                    None
356                }
357            })
358            .collect();
359
360        Ok(pruned)
361    }
362
363    /// ブランチを作成
364    pub fn create_branch(
365        &mut self,
366        branch_name: &str,
367        start_point: Option<&str>,
368    ) -> TwinResult<()> {
369        let mut args = vec!["branch", branch_name];
370
371        if let Some(start) = start_point {
372            args.push(start);
373        }
374
375        self.execute_git_command(&args)?;
376        info!("Branch created: {branch_name}");
377
378        Ok(())
379    }
380
381    /// ブランチを削除
382    pub fn delete_branch(&mut self, branch_name: &str, force: bool) -> TwinResult<()> {
383        let mut args = vec!["branch"];
384
385        if force {
386            args.push("-D");
387        } else {
388            args.push("-d");
389        }
390
391        args.push(branch_name);
392
393        self.execute_git_command(&args)?;
394        info!("Branch deleted: {branch_name}");
395
396        Ok(())
397    }
398
399    /// ブランチの一覧を取得
400    pub fn list_branches(&mut self, remote: bool) -> TwinResult<Vec<BranchInfo>> {
401        let mut args = vec!["branch", "-v"];
402
403        if remote {
404            args.push("-r");
405        } else {
406            args.push("-a");
407        }
408
409        let output = self.execute_git_command(&args)?;
410        let stdout = String::from_utf8_lossy(&output.stdout);
411
412        self.parse_branch_list(&stdout)
413    }
414
415    /// ブランチリストの出力をパース
416    fn parse_branch_list(&self, output: &str) -> TwinResult<Vec<BranchInfo>> {
417        let mut branches = Vec::new();
418
419        for line in output.lines() {
420            let line = line.trim();
421            if line.is_empty() {
422                continue;
423            }
424
425            let current = line.starts_with('*');
426            let line = if current { &line[2..] } else { line };
427
428            let parts: Vec<&str> = line.split_whitespace().collect();
429            if parts.len() < 2 {
430                continue;
431            }
432
433            let name = parts[0].to_string();
434            let commit = parts[1].to_string();
435
436            branches.push(BranchInfo {
437                name,
438                remote: None,
439                current,
440                commit,
441                ahead: 0,
442                behind: 0,
443            });
444        }
445
446        Ok(branches)
447    }
448
449    /// ブランチが存在するか確認
450    pub fn branch_exists(&mut self, branch_name: &str) -> TwinResult<bool> {
451        let branches = self.list_branches(false)?;
452        Ok(branches.iter().any(|b| b.name == branch_name))
453    }
454
455    /// ユニークなブランチ名を生成(既存のブランチと重複しないように)
456    pub fn generate_unique_branch_name(
457        &mut self,
458        base_name: &str,
459        max_attempts: usize,
460    ) -> TwinResult<String> {
461        // まず基本名を試す
462        if !self.branch_exists(base_name)? {
463            return Ok(base_name.to_string());
464        }
465
466        // 番号付きの名前を試す
467        for i in 1..=max_attempts {
468            let name = format!("{base_name}-{i}");
469            if !self.branch_exists(&name)? {
470                return Ok(name);
471            }
472        }
473
474        // タイムスタンプ付きの名前を生成
475        let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
476        let name = format!("{base_name}-{timestamp}");
477
478        if !self.branch_exists(&name)? {
479            Ok(name)
480        } else {
481            Err(TwinError::git(format!(
482                "Failed to generate unique branch name for: {base_name}"
483            )))
484        }
485    }
486
487    /// コマンド実行履歴を取得
488    pub fn get_command_history(&self) -> &[String] {
489        &self.command_history
490    }
491
492    /// コマンド実行履歴をクリア
493    pub fn clear_command_history(&mut self) {
494        self.command_history.clear();
495    }
496
497    /// リポジトリのルートパスを取得
498    pub fn get_repo_path(&self) -> &Path {
499        &self.repo_path
500    }
501
502    /// 現在のブランチ名を取得
503    pub fn get_current_branch(&mut self) -> TwinResult<String> {
504        let output = self.execute_git_command(&["rev-parse", "--abbrev-ref", "HEAD"])?;
505        let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
506        Ok(branch)
507    }
508
509    /// cdコマンド文字列を生成
510    pub fn generate_cd_command(&self, path: &Path) -> String {
511        format!("cd \"{}\"", path.display())
512    }
513
514    /// シェル関数用のヘルパースクリプトを生成
515    pub fn generate_shell_helper(&self, shell_type: ShellType) -> String {
516        match shell_type {
517            ShellType::Bash | ShellType::Zsh => r#"
518# Twin worktree helper function
519twin-switch() {
520    if [ -z "$1" ]; then
521        echo "Usage: twin-switch <agent-name>"
522        return 1
523    fi
524    
525    local path=$(twin switch "$1" --print-path)
526    if [ $? -eq 0 ] && [ -n "$path" ]; then
527        cd "$path"
528        echo "Switched to agent: $1"
529    else
530        echo "Failed to switch to agent: $1"
531        return 1
532    fi
533}
534
535# Twin create and switch function
536twin-create() {
537    if [ -z "$1" ]; then
538        echo "Usage: twin-create <agent-name>"
539        return 1
540    fi
541    
542    local path=$(twin create "$1" --print-path)
543    if [ $? -eq 0 ] && [ -n "$path" ]; then
544        cd "$path"
545        echo "Created and switched to agent: $1"
546    else
547        echo "Failed to create agent: $1"
548        return 1
549    fi
550}
551"#
552            .to_string(),
553            ShellType::PowerShell => r#"
554# Twin worktree helper function
555function Twin-Switch {
556    param(
557        [Parameter(Mandatory=$true)]
558        [string]$AgentName
559    )
560    
561    $path = twin switch $AgentName --print-path
562    if ($LASTEXITCODE -eq 0 -and $path) {
563        Set-Location $path
564        Write-Host "Switched to agent: $AgentName"
565    } else {
566        Write-Error "Failed to switch to agent: $AgentName"
567    }
568}
569
570# Twin create and switch function
571function Twin-Create {
572    param(
573        [Parameter(Mandatory=$true)]
574        [string]$AgentName
575    )
576    
577    $path = twin create $AgentName --print-path
578    if ($LASTEXITCODE -eq 0 -and $path) {
579        Set-Location $path
580        Write-Host "Created and switched to agent: $AgentName"
581    } else {
582        Write-Error "Failed to create agent: $AgentName"
583    }
584}
585"#
586            .to_string(),
587            ShellType::Fish => r#"
588# Twin worktree helper function
589function twin-switch
590    if test -z "$argv[1]"
591        echo "Usage: twin-switch <agent-name>"
592        return 1
593    end
594    
595    set -l path (twin switch $argv[1] --print-path)
596    if test $status -eq 0; and test -n "$path"
597        cd $path
598        echo "Switched to agent: $argv[1]"
599    else
600        echo "Failed to switch to agent: $argv[1]"
601        return 1
602    end
603end
604
605# Twin create and switch function
606function twin-create
607    if test -z "$argv[1]"
608        echo "Usage: twin-create <agent-name>"
609        return 1
610    end
611    
612    set -l path (twin create $argv[1] --print-path)
613    if test $status -eq 0; and test -n "$path"
614        cd $path
615        echo "Created and switched to agent: $argv[1]"
616    else
617        echo "Failed to create agent: $argv[1]"
618        return 1
619    end
620end
621"#
622            .to_string(),
623        }
624    }
625
626    /// エイリアス設定を生成
627    pub fn generate_aliases(&self, shell_type: ShellType) -> String {
628        match shell_type {
629            ShellType::Bash | ShellType::Zsh => r#"
630# Twin aliases
631alias tw='twin'
632alias tws='twin-switch'
633alias twc='twin-create'
634alias twl='twin list'
635alias twr='twin remove'
636"#
637            .to_string(),
638            ShellType::PowerShell => r#"
639# Twin aliases
640Set-Alias -Name tw -Value twin
641Set-Alias -Name tws -Value Twin-Switch
642Set-Alias -Name twc -Value Twin-Create
643Set-Alias -Name twl -Value 'twin list'
644Set-Alias -Name twr -Value 'twin remove'
645"#
646            .to_string(),
647            ShellType::Fish => r#"
648# Twin aliases
649alias tw='twin'
650alias tws='twin-switch'
651alias twc='twin-create'
652alias twl='twin list'
653alias twr='twin remove'
654"#
655            .to_string(),
656        }
657    }
658}
659
660/// サポートされているシェルタイプ
661#[allow(dead_code)]
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663pub enum ShellType {
664    Bash,
665    Zsh,
666    Fish,
667    PowerShell,
668}
669
670impl ShellType {
671    /// 現在のシェルを検出
672    pub fn detect() -> Option<Self> {
673        if cfg!(target_os = "windows") {
674            // Windows環境ではPowerShellをデフォルトとする
675            return Some(ShellType::PowerShell);
676        }
677
678        // Unix系環境では$SHELL環境変数を確認
679        if let Ok(shell) = std::env::var("SHELL") {
680            if shell.contains("bash") {
681                Some(ShellType::Bash)
682            } else if shell.contains("zsh") {
683                Some(ShellType::Zsh)
684            } else if shell.contains("fish") {
685                Some(ShellType::Fish)
686            } else {
687                // デフォルトはBash
688                Some(ShellType::Bash)
689            }
690        } else {
691            None
692        }
693    }
694
695    /// シェルタイプの文字列表現
696    pub fn as_str(&self) -> &str {
697        match self {
698            ShellType::Bash => "bash",
699            ShellType::Zsh => "zsh",
700            ShellType::Fish => "fish",
701            ShellType::PowerShell => "powershell",
702        }
703    }
704}