Skip to main content

twin_cli/cli/
commands.rs

1use crate::cli::output::OutputFormatter;
2use crate::cli::*;
3use crate::core::{Config, TwinError, TwinResult};
4
5// 後方互換性のためのcreateコマンドハンドラー
6pub async fn handle_create(args: AddArgs) -> TwinResult<()> {
7    handle_add(args).await
8}
9
10pub async fn handle_add(args: AddArgs) -> TwinResult<()> {
11    use crate::git::GitManager;
12    use crate::hooks::{HookContext, HookExecutor, HookType};
13    use crate::symlink::create_symlink_manager;
14    use std::path::PathBuf;
15
16    // 設定を読み込む
17    let config = if let Some(config_path) = &args.config {
18        Config::from_path(config_path)?
19    } else {
20        Config::new()
21    };
22
23    // ワークツリーのパスを決定
24    // パスが指定されていない場合は、worktree_base設定を使用
25    let worktree_path = if let Some(path) = &args.path {
26        path.clone()
27    } else {
28        // ブランチ名からディレクトリ名を作成(スラッシュをハイフンに置換)
29        let dir_name = args.branch.replace('/', "-");
30
31        // worktree_baseが設定されていればそれを使用、なければデフォルト
32        if let Some(base) = &config.settings.worktree_base {
33            base.join(&dir_name)
34        } else {
35            // デフォルトは ./worktrees/ブランチ名
36            PathBuf::from("worktrees").join(&dir_name)
37        }
38    };
39
40    // Git worktreeを作成
41    let mut git = GitManager::new(std::path::Path::new("."))?;
42
43    // git worktree addの引数を構築
44    let mut worktree_args = Vec::new();
45
46    // ブランチが存在するかチェック
47    let branch_exists = git.branch_exists(&args.branch)?;
48
49    // オプションを追加
50    if let Some(branch) = &args.new_branch {
51        worktree_args.push("-b");
52        worktree_args.push(branch.as_str());
53    } else if let Some(branch) = &args.force_branch {
54        worktree_args.push("-B");
55        worktree_args.push(branch.as_str());
56    } else if !args.no_create && !args.detach {
57        // デフォルトで新規ブランチを作成(--no-createオプションで無効化可能)
58        // ブランチが存在しない場合は-b、存在する場合は-Bを使用
59        if !branch_exists {
60            worktree_args.push("-b");
61        } else {
62            worktree_args.push("-B");
63        }
64        worktree_args.push(args.branch.as_str());
65    } else if !branch_exists && !args.detach && args.no_create {
66        // --no-createが指定されていて、ブランチが存在しない場合はエラー
67        return Err(TwinError::git(format!(
68            "Branch '{}' does not exist. Use without --no-create to create it automatically.",
69            args.branch
70        )));
71    }
72    if args.detach {
73        worktree_args.push("--detach");
74    }
75    if args.lock {
76        worktree_args.push("--lock");
77    }
78    if args.track {
79        worktree_args.push("--track");
80    }
81    if args.no_track {
82        worktree_args.push("--no-track");
83    }
84    if args.guess_remote {
85        worktree_args.push("--guess-remote");
86    }
87    if args.no_guess_remote {
88        worktree_args.push("--no-guess-remote");
89    }
90    if args.no_checkout {
91        worktree_args.push("--no-checkout");
92    }
93    if args.quiet {
94        worktree_args.push("--quiet");
95    }
96
97    // パスを追加
98    let path_str = worktree_path.to_string_lossy();
99    worktree_args.push(&path_str);
100
101    // ブランチ/コミットを追加
102    let branch_str = args.branch.clone();
103
104    // 新規ブランチ作成の場合、ブランチ参照は-b/-Bオプションで既に指定済み
105    // detachモードの場合、HEADをブランチ参照として使用
106    if args.new_branch.is_none() && args.force_branch.is_none() {
107        if !branch_exists && !args.detach {
108            // ブランチが存在しない場合(既に-bオプションを追加済み)
109            // ブランチ参照は不要
110        } else if args.detach {
111            // detachモードの場合、HEADを使用
112            worktree_args.push("HEAD");
113        } else {
114            // 既存のブランチを参照
115            worktree_args.push(&branch_str);
116        }
117    }
118
119    // worktreeのパスを正規化(絶対パスに)
120    let worktree_path_absolute = if worktree_path.is_relative() {
121        std::env::current_dir()?
122            .join(&worktree_path)
123            .canonicalize()
124            .unwrap_or_else(|_| {
125                // canonicalizeが失敗した場合(まだ存在しないパスの場合)
126                let cwd = std::env::current_dir().unwrap();
127                let mut result = cwd.clone();
128                for component in worktree_path.components() {
129                    match component {
130                        std::path::Component::ParentDir => {
131                            result.pop();
132                        }
133                        std::path::Component::Normal(name) => {
134                            result.push(name);
135                        }
136                        _ => {}
137                    }
138                }
139                result
140            })
141    } else {
142        worktree_path.clone()
143    };
144
145    // git_onlyモードの場合は副作用をスキップ
146    if args.git_only {
147        let output = git.add_worktree_with_options(&worktree_args)?;
148        if !args.quiet {
149            print!("{}", String::from_utf8_lossy(&output.stdout));
150        }
151        return Ok(());
152    }
153
154    // ブランチ名を決定
155    let branch_name = args
156        .new_branch
157        .as_ref()
158        .or(args.force_branch.as_ref())
159        .cloned()
160        .unwrap_or_else(|| args.branch.clone());
161
162    // フック実行の準備
163    let hook_executor = HookExecutor::new();
164    let hook_context = HookContext::new(
165        branch_name.clone(), // agent_nameの代わりにブランチ名を使用
166        worktree_path_absolute.clone(),
167        branch_name.clone(),
168        git.get_repo_path().to_path_buf(),
169    );
170
171    // pre_createフックを実行
172    if !config.settings.hooks.pre_create.is_empty() {
173        for hook in &config.settings.hooks.pre_create {
174            match hook_executor.execute(HookType::PreCreate, hook, &hook_context) {
175                Ok(result) => {
176                    if !result.success && !hook.continue_on_error {
177                        return Err(TwinError::hook(
178                            format!("Pre-create hook failed: {}", hook.command),
179                            "pre_create",
180                            result.exit_code,
181                        ));
182                    }
183                }
184                Err(e) if !hook.continue_on_error => return Err(e),
185                Err(e) => eprintln!("Warning: Pre-create hook failed: {e}"),
186            }
187        }
188    }
189
190    // 通常モード: git worktreeを実行して副作用を適用
191    let output = git.add_worktree_with_options(&worktree_args)?;
192    let _worktree_info = git.get_worktree_info(&worktree_path)?;
193
194    // シンボリックリンクを作成(副作用)
195    if !config.settings.files.is_empty() && !args.git_only {
196        let symlink_manager = create_symlink_manager();
197        let repo_root = git.get_repo_path();
198        let mut failed_links = Vec::new();
199
200        for mapping in &config.settings.files {
201            // ソースは絶対パスに変換(repo_rootが"."の場合は現在のディレクトリを使用)
202            let source = if repo_root == std::path::Path::new(".") {
203                std::env::current_dir()?.join(&mapping.path)
204            } else if repo_root.is_absolute() {
205                repo_root.join(&mapping.path)
206            } else {
207                std::env::current_dir()?.join(repo_root).join(&mapping.path)
208            };
209            let target = worktree_path_absolute.join(&mapping.path);
210
211            // ソースファイルが存在しない場合はスキップ
212            if !source.exists() {
213                eprintln!(
214                    "⚠️  Warning: Source file not found, skipping: {}",
215                    source.display()
216                );
217                failed_links.push(mapping.path.clone());
218                continue;
219            }
220
221            // ターゲットディレクトリを作成
222            if let Some(parent) = target.parent() {
223                if let Err(e) = std::fs::create_dir_all(parent) {
224                    eprintln!(
225                        "⚠️  Warning: Failed to create directory {}: {}",
226                        parent.display(),
227                        e
228                    );
229                    failed_links.push(mapping.path.clone());
230                    continue;
231                }
232            }
233
234            // シンボリックリンクを作成(エラー時は警告を表示して継続)
235            match symlink_manager.create_symlink(&source, &target) {
236                Ok(_) => {
237                    if !args.quiet {
238                        eprintln!(
239                            "✓ Created symlink: {} -> {}",
240                            target.display(),
241                            source.display()
242                        );
243                    }
244                }
245                Err(e) => {
246                    eprintln!(
247                        "⚠️  Warning: Failed to create symlink for {}: {}",
248                        mapping.path.display(),
249                        e
250                    );
251                    failed_links.push(mapping.path.clone());
252                }
253            }
254        }
255
256        // 失敗したリンクがある場合の警告
257        if !failed_links.is_empty() && !args.quiet {
258            eprintln!("⚠️  {} symlink(s) could not be created", failed_links.len());
259            eprintln!("   The worktree was created successfully, but some symlinks failed.");
260        }
261    }
262
263    // post_createフックを実行
264    if !config.settings.hooks.post_create.is_empty() {
265        for hook in &config.settings.hooks.post_create {
266            match hook_executor.execute(HookType::PostCreate, hook, &hook_context) {
267                Ok(result) => {
268                    if !result.success && !hook.continue_on_error {
269                        eprintln!("Error: Post-create hook failed: {}", hook.command);
270                        // post_createで失敗してもworktreeは既に作成済みなので、警告のみ
271                    }
272                }
273                Err(e) => eprintln!("Warning: Post-create hook failed: {e}"),
274            }
275        }
276    }
277
278    // パス表示やcdコマンド表示の処理
279    if args.print_path {
280        println!("{}", worktree_path_absolute.display());
281    } else if args.cd_command {
282        println!("cd \"{}\"", worktree_path_absolute.display());
283    } else if !args.quiet {
284        // git worktreeの出力をそのまま表示
285        print!("{}", String::from_utf8_lossy(&output.stdout));
286        if !config.settings.files.is_empty() {
287            println!("✓ シンボリックリンクを作成しました");
288        }
289    }
290
291    Ok(())
292}
293
294pub async fn handle_list(args: ListArgs) -> TwinResult<()> {
295    use crate::git::GitManager;
296
297    // git worktree list を使用
298    let mut git = GitManager::new(std::path::Path::new("."))?;
299    let worktrees = git.list_worktrees()?;
300
301    let formatter = OutputFormatter::new(&args.format);
302    formatter.format_worktrees(&worktrees)?;
303
304    Ok(())
305}
306
307pub async fn handle_remove(args: RemoveArgs) -> TwinResult<()> {
308    use crate::git::GitManager;
309    use crate::hooks::{HookContext, HookExecutor, HookType};
310    use crate::symlink::create_symlink_manager;
311    use std::path::PathBuf;
312
313    // Worktreeのパスかブランチ名で削除
314    let mut git = GitManager::new(std::path::Path::new("."))?;
315
316    // まずworktree一覧を取得して、対応するパスを探す
317    let worktrees = git.list_worktrees()?;
318    let worktree = worktrees.iter().find(|w| {
319        w.branch == args.worktree
320            || w.path.file_name().map(|n| n.to_string_lossy()) == Some(args.worktree.clone().into())
321            || w.path.to_string_lossy() == args.worktree
322    });
323
324    let path = if let Some(wt) = worktree {
325        wt.path.clone()
326    } else {
327        // パスとして解釈してみる
328        PathBuf::from(&args.worktree)
329    };
330
331    // 確認プロンプト
332    if !args.force {
333        use std::io::{self, Write};
334        print!("Worktree '{}' を削除しますか? [y/N]: ", path.display());
335        io::stdout().flush()?;
336
337        let mut input = String::new();
338        io::stdin().read_line(&mut input)?;
339
340        if !input.trim().eq_ignore_ascii_case("y") {
341            println!("削除をキャンセルしました");
342            return Ok(());
343        }
344    }
345
346    // 設定を読み込む
347    let config = if let Some(config_path) = &args.config {
348        Config::from_path(config_path)?
349    } else {
350        Config::new()
351    };
352
353    // フック実行の準備(削除時はブランチ名かパス名を使用)
354    let branch_name = worktree.map(|w| w.branch.clone()).unwrap_or_else(|| {
355        path.file_name()
356            .and_then(|n| n.to_str())
357            .unwrap_or("worktree")
358            .to_string()
359    });
360
361    let hook_executor = HookExecutor::new();
362    let hook_context = HookContext::new(
363        branch_name.clone(),
364        path.clone(),
365        branch_name.clone(),
366        git.get_repo_path().to_path_buf(),
367    );
368
369    // pre_removeフックを実行
370    if !config.settings.hooks.pre_remove.is_empty() && !args.git_only {
371        for hook in &config.settings.hooks.pre_remove {
372            match hook_executor.execute(HookType::PreRemove, hook, &hook_context) {
373                Ok(result) => {
374                    if !result.success && !hook.continue_on_error {
375                        return Err(TwinError::hook(
376                            format!("Pre-remove hook failed: {}", hook.command),
377                            "pre_remove",
378                            result.exit_code,
379                        ));
380                    }
381                }
382                Err(e) if !hook.continue_on_error => return Err(e),
383                Err(e) => eprintln!("Warning: Pre-remove hook failed: {e}"),
384            }
385        }
386    }
387
388    // シンボリックリンクを削除(副作用のクリーンアップ)
389
390    if !config.settings.files.is_empty() && !args.git_only {
391        let symlink_manager = create_symlink_manager();
392        let mut failed_cleanups = Vec::new();
393
394        for mapping in &config.settings.files {
395            let target = path.join(&mapping.path);
396
397            // シンボリックリンクが存在する場合のみ削除
398            if target.exists() || target.is_symlink() {
399                match symlink_manager.remove_symlink(&target) {
400                    Ok(_) => {
401                        if !args.quiet {
402                            eprintln!("✓ Removed symlink: {}", target.display());
403                        }
404                    }
405                    Err(e) => {
406                        eprintln!(
407                            "⚠️  Warning: Failed to remove symlink {}: {}",
408                            target.display(),
409                            e
410                        );
411                        failed_cleanups.push(mapping.path.clone());
412                    }
413                }
414            }
415        }
416
417        if !failed_cleanups.is_empty() && !args.quiet {
418            eprintln!(
419                "⚠️  {} symlink(s) could not be removed",
420                failed_cleanups.len()
421            );
422            eprintln!("   Proceeding with worktree removal anyway.");
423        }
424    }
425
426    // git worktree remove を実行
427    git.remove_worktree(&path, args.force)?;
428
429    // post_removeフックを実行
430    if !config.settings.hooks.post_remove.is_empty() && !args.git_only {
431        for hook in &config.settings.hooks.post_remove {
432            match hook_executor.execute(HookType::PostRemove, hook, &hook_context) {
433                Ok(result) => {
434                    if !result.success && !hook.continue_on_error {
435                        eprintln!("Error: Post-remove hook failed: {}", hook.command);
436                        // post_removeで失敗してもworktreeは既に削除済みなので、警告のみ
437                    }
438                }
439                Err(e) => eprintln!("Warning: Post-remove hook failed: {e}"),
440            }
441        }
442    }
443
444    println!("✓ Worktree '{}' を削除しました", path.display());
445
446    Ok(())
447}
448
449pub async fn handle_config(args: ConfigArgs) -> TwinResult<()> {
450    use std::path::PathBuf;
451
452    // 設定ファイルのパスを決定
453    let config_path = PathBuf::from(".twin.toml");
454
455    // サブコマンドの処理
456    if let Some(subcommand) = &args.subcommand {
457        match subcommand.as_str() {
458            "default" => {
459                // デフォルト設定をTOML形式で出力(コメント付き)
460                println!("# Twin設定ファイル (.twin.toml)");
461                println!("# このファイルをプロジェクトルートに配置してください");
462                println!();
463                println!("# Worktreeのベースディレクトリ(省略時: ../ブランチ名)");
464                println!("# worktree_base = \"../workspaces\"");
465                println!();
466                println!("# ファイルマッピング設定");
467                println!("# Worktree作成時に自動的にシンボリックリンクやコピーを作成します");
468                println!("# [[files]]");
469                println!("# path = \".env.template\"          # ソースファイルのパス");
470                println!("# mapping_type = \"copy\"           # \"symlink\" または \"copy\"");
471                println!("# description = \"環境変数設定\"     # 説明(省略可)");
472                println!("# skip_if_exists = true           # 既存ファイルをスキップ(省略可)");
473                println!();
474                println!("# [[files]]");
475                println!("# path = \".claude/config.json\"");
476                println!("# mapping_type = \"symlink\"");
477                println!();
478                println!("# フック設定(環境作成・削除時に実行するコマンド)");
479                println!("[hooks]");
480                println!("# pre_create = [");
481                println!("#   {{ command = \"echo\", args = [\"Creating: {{branch}}\"] }}");
482                println!("# ]");
483                println!("# post_create = [");
484                println!(
485                    "#   {{ command = \"npm\", args = [\"install\"], continue_on_error = true }}"
486                );
487                println!("# ]");
488                println!("# pre_remove = []");
489                println!("# post_remove = []");
490
491                return Ok(());
492            }
493            _ => {
494                println!("不明なサブコマンド: {subcommand}");
495                return Ok(());
496            }
497        }
498    }
499
500    if args.show {
501        // 現在の設定を表示
502        if config_path.exists() {
503            let config = Config::from_path(&config_path)?;
504            println!("{config:#?}");
505        } else {
506            println!("設定ファイルが見つかりません: {}", config_path.display());
507        }
508    } else if let Some(set_value) = args.set {
509        // 設定値をセット (key=value形式)
510        let parts: Vec<&str> = set_value.splitn(2, '=').collect();
511        if parts.len() != 2 {
512            return Err(crate::core::error::TwinError::Config {
513                message: "設定値は 'key=value' 形式で指定してください".to_string(),
514                path: None,
515                source: None,
516            });
517        }
518
519        println!("設定 '{}' を '{}' に設定しました", parts[0], parts[1]);
520        println!("注: この機能は現在実装中です");
521    } else if let Some(key) = args.get {
522        // 設定値を取得
523        if config_path.exists() {
524            let _config = Config::from_path(&config_path)?;
525            println!("キー '{key}' の値を取得します");
526            println!("注: この機能は現在実装中です");
527        } else {
528            println!("設定ファイルが見つかりません: {}", config_path.display());
529        }
530    } else {
531        println!("使用方法:");
532        println!("  twin config default         : デフォルト設定をTOML形式で出力");
533        println!("  twin config --show          : 現在の設定を表示");
534        println!("  twin config --set key=value : 設定値をセット");
535        println!("  twin config --get key       : 設定値を取得");
536    }
537
538    Ok(())
539}
540
541/// initコマンドのハンドラー
542pub async fn handle_init(args: InitArgs) -> TwinResult<()> {
543    // TODO(human): Add interactive mode support here
544    // When args.interactive is true, prompt user for:
545    // - worktree_base (default: "./worktrees")
546    // - branch_prefix (default: "agent/")
547    // Then pass these values to Config::init_with_options() or similar
548
549    // config::Config::init()を呼び出して設定ファイルを作成
550    let config_path = crate::config::Config::init(args.path, args.force).await?;
551
552    println!("✅ 設定ファイルを作成しました: {}", config_path.display());
553    println!();
554    println!("設定ファイルを編集して、プロジェクトに合わせてカスタマイズできます。");
555    println!("主な設定項目:");
556    println!("  - worktree_base: ワークツリーのベースディレクトリ");
557    println!("  - branch_prefix: ブランチ名のプレフィックス");
558    println!("  - files: シンボリックリンク/コピーするファイルマッピング");
559    println!("  - hooks: 各種フック(add, remove時の処理)");
560
561    Ok(())
562}