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