#![allow(dead_code)]
use crate::core::{TwinError, TwinResult};
use chrono::{DateTime, Local};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: String,
pub commit: String,
pub agent_name: Option<String>,
pub created_at: Option<DateTime<Local>>,
pub last_updated: Option<DateTime<Local>>,
pub locked: bool,
pub prunable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchInfo {
pub name: String,
pub remote: Option<String>,
pub current: bool,
pub commit: String,
pub ahead: usize,
pub behind: usize,
}
pub struct GitManager {
repo_path: PathBuf,
repository: Option<git2::Repository>,
command_history: Vec<String>,
dry_run: bool,
}
impl GitManager {
pub fn new(repo_path: &Path) -> TwinResult<Self> {
let repo_path = repo_path.to_path_buf();
let repository = match git2::Repository::open(&repo_path) {
Ok(repo) => {
info!("Opened Git repository at: {repo_path:?}");
Some(repo)
}
Err(e) => {
warn!("Failed to open repository with git2: {e}");
None
}
};
Self::verify_git_available()?;
Ok(Self {
repo_path,
repository,
command_history: Vec::new(),
dry_run: false,
})
}
pub fn set_dry_run(&mut self, dry_run: bool) {
self.dry_run = dry_run;
}
fn verify_git_available() -> TwinResult<()> {
let output = Command::new("git")
.arg("--version")
.output()
.map_err(|e| TwinError::git(format!("Git command not found: {e}")))?;
if !output.status.success() {
return Err(TwinError::git("Git command failed to execute"));
}
Ok(())
}
fn execute_git_command(&mut self, args: &[&str]) -> TwinResult<Output> {
let command_str = format!("git {}", args.join(" "));
info!("Executing: {command_str}");
self.command_history.push(command_str.clone());
if std::env::var("TWIN_VERBOSE").is_ok() || std::env::var("TWIN_DEBUG").is_ok() {
eprintln!("🔧 実行中: {command_str}");
}
if self.dry_run {
info!("[DRY RUN] Would execute: {command_str}");
if std::env::var("TWIN_VERBOSE").is_ok() || std::env::var("TWIN_DEBUG").is_ok() {
eprintln!("📝 ドライラン: {command_str}");
}
return Ok(Output {
#[cfg(unix)]
status: std::os::unix::process::ExitStatusExt::from_raw(0),
#[cfg(windows)]
status: std::os::windows::process::ExitStatusExt::from_raw(0),
stdout: b"[DRY RUN]".to_vec(),
stderr: Vec::new(),
});
}
let output = Command::new("git")
.current_dir(&self.repo_path)
.args(args)
.output()
.map_err(|e| TwinError::git(format!("Failed to execute git command: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TwinError::git(format!("Git command failed: {stderr}")));
}
Ok(output)
}
pub fn add_worktree(
&mut self,
path: &Path,
branch: Option<&str>,
create_branch: bool,
) -> TwinResult<WorktreeInfo> {
let mut args = vec!["worktree", "add"];
if create_branch {
if let Some(b) = branch {
args.push("-b");
args.push(b);
}
}
let path_str = path.to_string_lossy();
args.push(&path_str);
if !create_branch {
if let Some(b) = branch {
args.push(b);
}
}
let output = self.execute_git_command(&args)?;
debug!(
"Worktree added: {:?}",
String::from_utf8_lossy(&output.stdout)
);
self.get_worktree_info(path)
}
pub fn add_worktree_with_options(&mut self, args: &[&str]) -> TwinResult<Output> {
let mut full_args = vec!["worktree", "add"];
full_args.extend_from_slice(args);
self.execute_git_command_raw(&full_args)
}
pub fn execute_git_command_raw(&mut self, args: &[&str]) -> TwinResult<Output> {
let command_str = format!("git {}", args.join(" "));
info!("Executing: {command_str}");
if self.dry_run {
info!("[DRY RUN] Would execute: {command_str}");
return Ok(Output {
#[cfg(unix)]
status: std::os::unix::process::ExitStatusExt::from_raw(0),
#[cfg(windows)]
status: std::os::windows::process::ExitStatusExt::from_raw(0),
stdout: b"[DRY RUN]".to_vec(),
stderr: Vec::new(),
});
}
let output = Command::new("git")
.args(args)
.current_dir(&self.repo_path)
.output()
.map_err(|e| TwinError::git(format!("{e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TwinError::git(stderr.trim().to_string()));
}
Ok(output)
}
pub fn remove_worktree(&mut self, path: &Path, force: bool) -> TwinResult<()> {
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
let path_str = path.to_string_lossy();
args.push(&path_str);
self.execute_git_command(&args)?;
info!("Worktree removed: {path:?}");
Ok(())
}
pub fn list_worktrees(&mut self) -> TwinResult<Vec<WorktreeInfo>> {
let output = self.execute_git_command(&["worktree", "list", "--porcelain"])?;
let stdout = String::from_utf8_lossy(&output.stdout);
self.parse_worktree_list(&stdout)
}
fn parse_worktree_list(&self, output: &str) -> TwinResult<Vec<WorktreeInfo>> {
let mut worktrees = Vec::new();
let mut current_worktree: Option<WorktreeInfo> = None;
for line in output.lines() {
if line.starts_with("worktree ") {
if let Some(wt) = current_worktree.take() {
worktrees.push(wt);
}
let path = PathBuf::from(line.strip_prefix("worktree ").unwrap());
current_worktree = Some(WorktreeInfo {
path,
branch: String::new(),
commit: String::new(),
agent_name: None,
created_at: None,
last_updated: None,
locked: false,
prunable: false,
});
} else if let Some(ref mut wt) = current_worktree {
if line.starts_with("HEAD ") {
wt.commit = line.strip_prefix("HEAD ").unwrap().to_string();
} else if line.starts_with("branch ") {
wt.branch = line.strip_prefix("branch ").unwrap().to_string();
if wt.branch.starts_with("agent/") {
wt.agent_name = Some(wt.branch[6..].to_string());
}
} else if line == "locked" {
wt.locked = true;
} else if line == "prunable" {
wt.prunable = true;
}
}
}
if let Some(wt) = current_worktree {
worktrees.push(wt);
}
Ok(worktrees)
}
pub fn get_worktree_info(&mut self, path: &Path) -> TwinResult<WorktreeInfo> {
let worktrees = self.list_worktrees()?;
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| TwinError::io(format!("Failed to get current dir: {e}"), None))?
.join(path)
};
let canonical_path = abs_path.canonicalize().ok();
worktrees
.into_iter()
.find(|wt| {
wt.path == path ||
wt.path == abs_path ||
canonical_path.as_ref().is_some_and(|cp| {
wt.path.canonicalize().ok().is_some_and(|wtp| wtp == *cp)
}) ||
wt.path.file_name() == path.file_name() && path.file_name().is_some()
})
.ok_or_else(|| TwinError::not_found("Worktree", path.to_string_lossy().to_string()))
}
pub fn prune_worktrees(&mut self, dry_run: bool) -> TwinResult<Vec<PathBuf>> {
let mut args = vec!["worktree", "prune"];
if dry_run {
args.push("--dry-run");
}
let output = self.execute_git_command(&args)?;
let stdout = String::from_utf8_lossy(&output.stdout);
let pruned: Vec<PathBuf> = stdout
.lines()
.filter_map(|line| {
if line.contains("Removing worktrees") {
Some(PathBuf::from(line.rsplit(":").next()?.trim()))
} else {
None
}
})
.collect();
Ok(pruned)
}
pub fn create_branch(
&mut self,
branch_name: &str,
start_point: Option<&str>,
) -> TwinResult<()> {
let mut args = vec!["branch", branch_name];
if let Some(start) = start_point {
args.push(start);
}
self.execute_git_command(&args)?;
info!("Branch created: {branch_name}");
Ok(())
}
pub fn delete_branch(&mut self, branch_name: &str, force: bool) -> TwinResult<()> {
let mut args = vec!["branch"];
if force {
args.push("-D");
} else {
args.push("-d");
}
args.push(branch_name);
self.execute_git_command(&args)?;
info!("Branch deleted: {branch_name}");
Ok(())
}
pub fn list_branches(&mut self, remote: bool) -> TwinResult<Vec<BranchInfo>> {
let mut args = vec!["branch", "-v"];
if remote {
args.push("-r");
} else {
args.push("-a");
}
let output = self.execute_git_command(&args)?;
let stdout = String::from_utf8_lossy(&output.stdout);
self.parse_branch_list(&stdout)
}
fn parse_branch_list(&self, output: &str) -> TwinResult<Vec<BranchInfo>> {
let mut branches = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let current = line.starts_with('*');
let line = if current { &line[2..] } else { line };
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
continue;
}
let name = parts[0].to_string();
let commit = parts[1].to_string();
branches.push(BranchInfo {
name,
remote: None,
current,
commit,
ahead: 0,
behind: 0,
});
}
Ok(branches)
}
pub fn branch_exists(&mut self, branch_name: &str) -> TwinResult<bool> {
let branches = self.list_branches(false)?;
Ok(branches.iter().any(|b| b.name == branch_name))
}
pub fn generate_unique_branch_name(
&mut self,
base_name: &str,
max_attempts: usize,
) -> TwinResult<String> {
if !self.branch_exists(base_name)? {
return Ok(base_name.to_string());
}
for i in 1..=max_attempts {
let name = format!("{base_name}-{i}");
if !self.branch_exists(&name)? {
return Ok(name);
}
}
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let name = format!("{base_name}-{timestamp}");
if !self.branch_exists(&name)? {
Ok(name)
} else {
Err(TwinError::git(format!(
"Failed to generate unique branch name for: {base_name}"
)))
}
}
pub fn get_command_history(&self) -> &[String] {
&self.command_history
}
pub fn clear_command_history(&mut self) {
self.command_history.clear();
}
pub fn get_repo_path(&self) -> &Path {
&self.repo_path
}
pub fn get_current_branch(&mut self) -> TwinResult<String> {
let output = self.execute_git_command(&["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(branch)
}
pub fn generate_cd_command(&self, path: &Path) -> String {
format!("cd \"{}\"", path.display())
}
pub fn generate_shell_helper(&self, shell_type: ShellType) -> String {
match shell_type {
ShellType::Bash | ShellType::Zsh => r#"
# Twin worktree helper function
twin-switch() {
if [ -z "$1" ]; then
echo "Usage: twin-switch <agent-name>"
return 1
fi
local path=$(twin switch "$1" --print-path)
if [ $? -eq 0 ] && [ -n "$path" ]; then
cd "$path"
echo "Switched to agent: $1"
else
echo "Failed to switch to agent: $1"
return 1
fi
}
# Twin create and switch function
twin-create() {
if [ -z "$1" ]; then
echo "Usage: twin-create <agent-name>"
return 1
fi
local path=$(twin create "$1" --print-path)
if [ $? -eq 0 ] && [ -n "$path" ]; then
cd "$path"
echo "Created and switched to agent: $1"
else
echo "Failed to create agent: $1"
return 1
fi
}
"#
.to_string(),
ShellType::PowerShell => r#"
# Twin worktree helper function
function Twin-Switch {
param(
[Parameter(Mandatory=$true)]
[string]$AgentName
)
$path = twin switch $AgentName --print-path
if ($LASTEXITCODE -eq 0 -and $path) {
Set-Location $path
Write-Host "Switched to agent: $AgentName"
} else {
Write-Error "Failed to switch to agent: $AgentName"
}
}
# Twin create and switch function
function Twin-Create {
param(
[Parameter(Mandatory=$true)]
[string]$AgentName
)
$path = twin create $AgentName --print-path
if ($LASTEXITCODE -eq 0 -and $path) {
Set-Location $path
Write-Host "Created and switched to agent: $AgentName"
} else {
Write-Error "Failed to create agent: $AgentName"
}
}
"#
.to_string(),
ShellType::Fish => r#"
# Twin worktree helper function
function twin-switch
if test -z "$argv[1]"
echo "Usage: twin-switch <agent-name>"
return 1
end
set -l path (twin switch $argv[1] --print-path)
if test $status -eq 0; and test -n "$path"
cd $path
echo "Switched to agent: $argv[1]"
else
echo "Failed to switch to agent: $argv[1]"
return 1
end
end
# Twin create and switch function
function twin-create
if test -z "$argv[1]"
echo "Usage: twin-create <agent-name>"
return 1
end
set -l path (twin create $argv[1] --print-path)
if test $status -eq 0; and test -n "$path"
cd $path
echo "Created and switched to agent: $argv[1]"
else
echo "Failed to create agent: $argv[1]"
return 1
end
end
"#
.to_string(),
}
}
pub fn generate_aliases(&self, shell_type: ShellType) -> String {
match shell_type {
ShellType::Bash | ShellType::Zsh => r#"
# Twin aliases
alias tw='twin'
alias tws='twin-switch'
alias twc='twin-create'
alias twl='twin list'
alias twr='twin remove'
"#
.to_string(),
ShellType::PowerShell => r#"
# Twin aliases
Set-Alias -Name tw -Value twin
Set-Alias -Name tws -Value Twin-Switch
Set-Alias -Name twc -Value Twin-Create
Set-Alias -Name twl -Value 'twin list'
Set-Alias -Name twr -Value 'twin remove'
"#
.to_string(),
ShellType::Fish => r#"
# Twin aliases
alias tw='twin'
alias tws='twin-switch'
alias twc='twin-create'
alias twl='twin list'
alias twr='twin remove'
"#
.to_string(),
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellType {
Bash,
Zsh,
Fish,
PowerShell,
}
impl ShellType {
pub fn detect() -> Option<Self> {
if cfg!(target_os = "windows") {
return Some(ShellType::PowerShell);
}
if let Ok(shell) = std::env::var("SHELL") {
if shell.contains("bash") {
Some(ShellType::Bash)
} else if shell.contains("zsh") {
Some(ShellType::Zsh)
} else if shell.contains("fish") {
Some(ShellType::Fish)
} else {
Some(ShellType::Bash)
}
} else {
None
}
}
pub fn as_str(&self) -> &str {
match self {
ShellType::Bash => "bash",
ShellType::Zsh => "zsh",
ShellType::Fish => "fish",
ShellType::PowerShell => "powershell",
}
}
}