1#![allow(dead_code)]
2use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorktreeInfo {
19 pub path: PathBuf,
21 pub branch: String,
23 pub commit: String,
25 pub agent_name: Option<String>,
27 pub created_at: Option<DateTime<Local>>,
29 pub last_updated: Option<DateTime<Local>>,
31 pub locked: bool,
33 pub prunable: bool,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct BranchInfo {
40 pub name: String,
42 pub remote: Option<String>,
44 pub current: bool,
46 pub commit: String,
48 pub ahead: usize,
50 pub behind: usize,
51}
52
53pub struct GitManager {
55 repo_path: PathBuf,
57 repository: Option<git2::Repository>,
59 command_history: Vec<String>,
61 dry_run: bool,
63}
64
65impl GitManager {
66 pub fn new(repo_path: &Path) -> TwinResult<Self> {
68 let repo_path = repo_path.to_path_buf();
69
70 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 None
80 }
81 };
82
83 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 pub fn set_dry_run(&mut self, dry_run: bool) {
96 self.dry_run = dry_run;
97 }
98
99 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 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 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 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 if create_branch {
164 if let Some(b) = branch {
165 args.push("-b");
166 args.push(b);
167 }
168 }
169
170 let path_str = path.to_string_lossy();
172 args.push(&path_str);
173
174 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 self.get_worktree_info(path)
189 }
190
191 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 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 return Err(TwinError::git(stderr.trim().to_string()));
226 }
227
228 Ok(output)
229 }
230
231 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 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 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 if let Some(wt) = current_worktree.take() {
265 worktrees.push(wt);
266 }
267
268 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 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 if let Some(wt) = current_worktree {
299 worktrees.push(wt);
300 }
301
302 Ok(worktrees)
303 }
304
305 pub fn get_worktree_info(&mut self, path: &Path) -> TwinResult<WorktreeInfo> {
307 let worktrees = self.list_worktrees()?;
308
309 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 let canonical_path = abs_path.canonicalize().ok();
320
321 worktrees
322 .into_iter()
323 .find(|wt| {
324 wt.path == path ||
326 wt.path == abs_path ||
327 canonical_path.as_ref().is_some_and(|cp| {
329 wt.path.canonicalize().ok().is_some_and(|wtp| wtp == *cp)
330 }) ||
331 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 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 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 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 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 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 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 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 pub fn generate_unique_branch_name(
457 &mut self,
458 base_name: &str,
459 max_attempts: usize,
460 ) -> TwinResult<String> {
461 if !self.branch_exists(base_name)? {
463 return Ok(base_name.to_string());
464 }
465
466 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 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 pub fn get_command_history(&self) -> &[String] {
489 &self.command_history
490 }
491
492 pub fn clear_command_history(&mut self) {
494 self.command_history.clear();
495 }
496
497 pub fn get_repo_path(&self) -> &Path {
499 &self.repo_path
500 }
501
502 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 pub fn generate_cd_command(&self, path: &Path) -> String {
511 format!("cd \"{}\"", path.display())
512 }
513
514 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 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#[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 pub fn detect() -> Option<Self> {
673 if cfg!(target_os = "windows") {
674 return Some(ShellType::PowerShell);
676 }
677
678 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 Some(ShellType::Bash)
689 }
690 } else {
691 None
692 }
693 }
694
695 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}