pub mod audit;
pub use audit::run_audit;
use anyhow::{Context, Result, bail};
use colored::*;
use git2::Repository;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Default)]
pub struct WorktreeNote {
pub note: Option<String>,
pub ref_: Option<String>,
}
fn gwtr_dir(repo: &Repository) -> Result<PathBuf> {
let git_dir = repo.path();
Ok(git_dir.join("gwtr"))
}
fn note_path(repo: &Repository, worktree_name: &str) -> Result<PathBuf> {
Ok(gwtr_dir(repo)?.join(format!("{}.json", worktree_name)))
}
fn load_note(repo: &Repository, worktree_name: &str) -> Result<WorktreeNote> {
let path = note_path(repo, worktree_name)?;
if !path.exists() {
return Ok(WorktreeNote::default());
}
let content = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
}
fn save_note(repo: &Repository, worktree_name: &str, note: &WorktreeNote) -> Result<()> {
let dir = gwtr_dir(repo)?;
std::fs::create_dir_all(&dir)?;
let path = note_path(repo, worktree_name)?;
std::fs::write(path, serde_json::to_string_pretty(note)?)?;
Ok(())
}
fn delete_note(repo: &Repository, worktree_name: &str) -> Result<()> {
let path = note_path(repo, worktree_name)?;
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
pub fn ensure_git_repository(path: &Path) -> Result<Repository> {
Repository::discover(path)
.context("Not in a git repository. Please run this command inside a git repository.")
}
pub fn get_repository_name(repo: &Repository) -> Result<String> {
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let repo_name = workdir
.file_name()
.context("Failed to get repository directory name")?
.to_str()
.context("Repository name contains invalid UTF-8")?;
Ok(repo_name.to_string())
}
pub fn create_worktree(repo: &Repository, worktree_name: &str) -> Result<PathBuf> {
let repo_name = get_repository_name(repo)?;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let parent_dir = workdir
.parent()
.context("Failed to get parent directory of repository")?;
let worktree_path = parent_dir.join(format!("{}_{}", repo_name, worktree_name));
if worktree_path.exists() {
bail!(
"Worktree '{}' already exists at {:?}",
worktree_name,
worktree_path
);
}
use std::process::Command;
let output = Command::new("git")
.args([
"worktree",
"add",
"-b",
worktree_name,
worktree_path.to_str().unwrap(),
])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
let output = Command::new("git")
.args([
"worktree",
"add",
worktree_path.to_str().unwrap(),
worktree_name,
])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create worktree: {}", stderr);
}
} else {
bail!("Failed to create worktree: {}", stderr);
}
}
println!(
"Created worktree '{}' at {:?}",
worktree_name, worktree_path
);
println!("cd '{}'", worktree_path.display());
Ok(worktree_path)
}
pub fn list_worktrees(repo: &Repository) -> Result<()> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree list command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list worktrees: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
if lines.is_empty() {
println!("No worktrees found");
return Ok(());
}
let mut worktrees = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with("worktree ") {
let path = lines[i].strip_prefix("worktree ").unwrap_or("");
let mut branch = "detached";
let mut is_bare = false;
if i + 1 < lines.len() && lines[i + 1].starts_with("HEAD ") {
i += 1;
}
if i + 1 < lines.len() && lines[i + 1].starts_with("branch ") {
branch = lines[i + 1]
.strip_prefix("branch refs/heads/")
.unwrap_or("unknown");
i += 1;
}
if i + 1 < lines.len() && lines[i + 1] == "bare" {
is_bare = true;
i += 1;
}
worktrees.push((path.to_string(), branch.to_string(), is_bare));
}
i += 1;
}
println!("{}", "Worktrees:".bold());
let main_path = workdir.to_string_lossy().trim_end_matches('/').to_string();
for (path, branch, is_bare) in worktrees {
let normalized_path = path.trim_end_matches('/');
let is_main = normalized_path == main_path;
let display_path = if is_main {
format!("{} (main)", path).green()
} else {
path.yellow()
};
if is_bare {
println!(" {} [bare]", display_path);
} else {
println!(" {} [{}]", display_path, branch.cyan());
}
}
Ok(())
}
pub fn remove_worktree(repo: &Repository, worktree_name: &str) -> Result<()> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let repo_name = get_repository_name(repo)?;
let parent_dir = workdir
.parent()
.context("Failed to get parent directory of repository")?;
let worktree_path = parent_dir.join(format!("{}_{}", repo_name, worktree_name));
if !worktree_path.exists() {
bail!(
"Worktree '{}' not found at {:?}",
worktree_name,
worktree_path
);
}
let output = Command::new("git")
.args(["worktree", "remove", worktree_path.to_str().unwrap()])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree remove command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("contains modified or untracked files") {
println!("Worktree contains uncommitted changes, removing with --force");
let output = Command::new("git")
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree remove command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to remove worktree: {}", stderr);
}
} else {
bail!("Failed to remove worktree: {}", stderr);
}
}
delete_note(repo, worktree_name)?;
println!(
"Removed worktree '{}' at {:?}",
worktree_name, worktree_path
);
Ok(())
}
pub fn show_worktrees_status(repo: &Repository) -> Result<()> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree list command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list worktrees: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let mut worktrees = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with("worktree ") {
let path = lines[i].strip_prefix("worktree ").unwrap_or("");
let mut branch = "detached";
if i + 1 < lines.len() && lines[i + 1].starts_with("HEAD ") {
i += 1;
}
if i + 1 < lines.len() && lines[i + 1].starts_with("branch ") {
branch = lines[i + 1]
.strip_prefix("branch refs/heads/")
.unwrap_or("unknown");
i += 1;
}
worktrees.push((path.to_string(), branch.to_string()));
}
i += 1;
}
println!("{}", "Worktrees:".bold());
let main_path = workdir.to_string_lossy().trim_end_matches('/').to_string();
for (path, branch) in worktrees {
let normalized_path = path.trim_end_matches('/');
let is_main = normalized_path == main_path;
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&path)
.output();
let status_msg = if let Ok(output) = status_output {
if output.status.success() {
let status_str = String::from_utf8_lossy(&output.stdout);
if status_str.trim().is_empty() {
"clean".green().to_string()
} else {
let change_count = status_str.lines().count();
format!("{} uncommitted changes", change_count)
.yellow()
.to_string()
}
} else {
"unknown".red().to_string()
}
} else {
"error".red().to_string()
};
let display_path = if is_main {
format!("{} (main)", path).green()
} else {
path.yellow()
};
println!(" {} [{}] - {}", display_path, branch.cyan(), status_msg);
}
Ok(())
}
pub fn pull_all_worktrees(repo: &Repository) -> Result<()> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
println!("Pulling all worktrees from origin/main...");
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree list command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list worktrees: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let mut worktree_paths = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with("worktree ") {
let path = lines[i].strip_prefix("worktree ").unwrap_or("");
let mut branch = "detached";
if i + 1 < lines.len() && lines[i + 1].starts_with("HEAD ") {
i += 1;
}
if i + 1 < lines.len() && lines[i + 1].starts_with("branch ") {
branch = lines[i + 1]
.strip_prefix("branch refs/heads/")
.unwrap_or("unknown");
i += 1;
}
worktree_paths.push((path.to_string(), branch.to_string()));
}
i += 1;
}
let main_path = workdir.to_string_lossy().trim_end_matches('/').to_string();
for (path, branch) in worktree_paths {
let normalized_path = path.trim_end_matches('/');
let is_main = normalized_path == main_path;
let worktree_name = if is_main {
"main".to_string()
} else {
std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&branch)
.to_string()
};
print!(" {} [{}]: ", worktree_name.yellow(), branch.cyan());
let pull_output = Command::new("git")
.args(["pull", "origin", "main"])
.current_dir(&path)
.output();
match pull_output {
Ok(output) => {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("Already up to date")
|| stdout.contains("Already up-to-date")
{
println!("{}", "Already up to date".green());
} else {
println!("{}", "Updated".green());
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Could not find remote")
|| stderr.contains("fatal: 'origin' does not appear to be a git repository")
{
println!("{}: No remote configured", "Skipped".yellow());
} else {
println!("{}: {}", "Failed".red(), stderr.trim());
}
}
}
Err(e) => {
println!("{}: {}", "Error".red(), e);
}
}
}
Ok(())
}
pub fn pull_worktree(repo: &Repository, worktree_name: &str) -> Result<()> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let repo_name = get_repository_name(repo)?;
let parent_dir = workdir
.parent()
.context("Failed to get parent directory of repository")?;
let worktree_path = if worktree_name == "main" {
workdir.to_path_buf()
} else {
let path = parent_dir.join(format!("{}_{}", repo_name, worktree_name));
if !path.exists() {
bail!("Worktree '{}' not found", worktree_name);
}
path
};
println!("Pulling worktree '{}' from origin/main...", worktree_name);
let output = Command::new("git")
.args(["pull", "origin", "main"])
.current_dir(&worktree_path)
.output()
.context("Failed to execute git pull command")?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
println!(
"{}: {}",
worktree_name.yellow(),
"Already up to date".green()
);
} else {
println!("{}: {}", worktree_name.yellow(), "Updated".green());
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Could not find remote")
|| stderr.contains("fatal: 'origin' does not appear to be a git repository")
{
bail!("No remote 'origin' configured");
} else {
bail!("Failed to pull worktree '{}': {}", worktree_name, stderr);
}
}
Ok(())
}
pub fn pull_current_worktree(repo: &Repository) -> Result<()> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
println!("Pulling current worktree from origin/main...");
let output = Command::new("git")
.args(["pull", "origin", "main"])
.current_dir(workdir)
.output()
.context("Failed to execute git pull command")?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
println!("{}", "Already up to date".green());
} else {
println!("{}", "Updated".green());
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Could not find remote")
|| stderr.contains("fatal: 'origin' does not appear to be a git repository")
{
bail!("No remote 'origin' configured");
} else {
bail!("Failed to pull: {}", stderr);
}
}
Ok(())
}
pub fn prune_merged_worktrees(repo: &Repository, dry_run: bool, force: bool) -> Result<()> {
use std::io::{self, Write};
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let repo_name = get_repository_name(repo)?;
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree list command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list worktrees: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let main_path = workdir.to_string_lossy().trim_end_matches('/').to_string();
let mut merged_worktrees = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with("worktree ") {
let path = lines[i].strip_prefix("worktree ").unwrap_or("");
let normalized_path = path.trim_end_matches('/');
if normalized_path == main_path {
i += 1;
continue;
}
if i + 1 < lines.len() && lines[i + 1].starts_with("HEAD ") {
i += 1;
}
if i + 1 < lines.len() && lines[i + 1].starts_with("branch ") {
let branch = lines[i + 1]
.strip_prefix("branch refs/heads/")
.unwrap_or("unknown");
i += 1;
let merged_output = Command::new("git")
.args(["branch", "--merged", "main"])
.current_dir(workdir)
.output();
if let Ok(output) = merged_output
&& output.status.success()
{
let merged_branches = String::from_utf8_lossy(&output.stdout);
if merged_branches
.lines()
.any(|line| line.trim() == branch || line.trim() == format!("* {}", branch))
{
let worktree_name = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(branch)
.to_string();
let worktree_name = if worktree_name.starts_with(&format!("{}_", repo_name))
{
worktree_name
.strip_prefix(&format!("{}_", repo_name))
.unwrap_or(&worktree_name)
.to_string()
} else {
worktree_name
};
merged_worktrees.push((
path.to_string(),
branch.to_string(),
worktree_name,
));
}
}
}
}
i += 1;
}
if merged_worktrees.is_empty() {
println!("No worktrees to prune");
return Ok(());
}
if dry_run {
println!(
"Would prune {} merged worktree{}:",
merged_worktrees.len(),
if merged_worktrees.len() == 1 { "" } else { "s" }
);
for (path, branch, name) in &merged_worktrees {
println!(" {} [{}] at {}", name.yellow(), branch.cyan(), path);
}
return Ok(());
}
println!(
"Found {} merged worktree{} to prune:",
merged_worktrees.len(),
if merged_worktrees.len() == 1 { "" } else { "s" }
);
for (path, branch, name) in &merged_worktrees {
println!(" {} [{}] at {}", name.yellow(), branch.cyan(), path);
}
if !force {
print!("\nPrune these worktrees? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Cancelled");
return Ok(());
}
}
let pruned_count = merged_worktrees.len();
for (path, _, name) in merged_worktrees {
print!("Pruning {}... ", name.yellow());
let output = Command::new("git")
.args(["worktree", "remove", &path])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree remove command")?;
if output.status.success() {
delete_note(repo, &name)?;
println!("{}", "done".green());
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("contains modified or untracked files") {
print!("has uncommitted changes, removing with --force... ");
let output = Command::new("git")
.args(["worktree", "remove", "--force", &path])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree remove command")?;
if output.status.success() {
delete_note(repo, &name)?;
println!("{}", "done".green());
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("{}", "failed".red());
eprintln!(" Error: {}", stderr.trim());
}
} else {
println!("{}", "failed".red());
eprintln!(" Error: {}", stderr.trim());
}
}
}
println!(
"\nPruned {} worktree{}",
pruned_count,
if pruned_count == 1 { "" } else { "s" }
);
Ok(())
}
pub fn manage_note(
repo: &Repository,
worktree_name: &str,
text: Option<&str>,
ref_: Option<&str>,
) -> Result<()> {
let mut note = load_note(repo, worktree_name)?;
if text.is_none() && ref_.is_none() {
println!("{}", format!("Note for '{}':", worktree_name).bold());
match ¬e.note {
Some(n) => println!(" Note: {}", n),
None => println!(" Note: {}", "(none)".dimmed()),
}
match ¬e.ref_ {
Some(r) => println!(" Ref: {}", r.cyan()),
None => println!(" Ref: {}", "(none)".dimmed()),
}
return Ok(());
}
if let Some(t) = text {
note.note = Some(t.to_string());
}
if let Some(r) = ref_ {
note.ref_ = Some(r.to_string());
}
save_note(repo, worktree_name, ¬e)?;
println!("Saved note for '{}'", worktree_name.yellow());
Ok(())
}
pub fn show_context(repo: &Repository, worktree_name: Option<&str>, save: bool) -> Result<()> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let repo_name = get_repository_name(repo)?;
let parent_dir = workdir
.parent()
.context("Failed to get parent directory")?;
let mut output_lines: Vec<String> = Vec::new();
let worktrees = collect_worktrees(repo)?;
let targets: Vec<(String, String)> = if let Some(name) = worktree_name {
let path = parent_dir.join(format!("{}_{}", repo_name, name));
if !path.exists() {
bail!("Worktree '{}' not found", name);
}
vec![(name.to_string(), path.to_string_lossy().to_string())]
} else {
worktrees
.iter()
.filter(|(_, path)| {
path.trim_end_matches('/') != workdir.to_string_lossy().trim_end_matches('/')
})
.map(|(branch, path)| {
let name = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_prefix(&format!("{}_", repo_name)))
.unwrap_or(branch)
.to_string();
(name, path.clone())
})
.collect()
};
let detailed = worktree_name.is_some();
for (name, path) in &targets {
let note = load_note(repo, name)?;
output_lines.push(format!("## {}", name));
if let Some(n) = ¬e.note {
output_lines.push(format!("Note: {}", n));
}
if let Some(r) = ¬e.ref_ {
output_lines.push(format!("Ref: {}", r));
}
let branch_out = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(path)
.output();
if let Ok(o) = branch_out {
let branch = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !branch.is_empty() {
output_lines.push(format!("Branch: {}", branch));
}
}
let diff_out = Command::new("git")
.args(["diff", "main", "--stat"])
.current_dir(path)
.output();
if let Ok(o) = diff_out {
let stat = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !stat.is_empty() {
output_lines.push(String::new());
output_lines.push("### Changed files (vs main)".to_string());
for line in stat.lines() {
output_lines.push(format!(" {}", line));
}
}
}
let log_args = if detailed {
vec!["log", "main..HEAD", "--oneline", "--no-decorate"]
} else {
vec!["log", "main..HEAD", "--oneline", "--no-decorate", "-5"]
};
let log_out = Command::new("git")
.args(&log_args)
.current_dir(path)
.output();
if let Ok(o) = log_out {
let log = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !log.is_empty() {
output_lines.push(String::new());
output_lines.push("### Commits (vs main)".to_string());
for line in log.lines() {
output_lines.push(format!(" {}", line));
}
}
}
let status_out = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output();
if let Ok(o) = status_out {
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
output_lines.push(String::new());
if status.is_empty() {
output_lines.push("### Uncommitted changes: none".to_string());
} else {
output_lines.push("### Uncommitted changes".to_string());
for line in status.lines() {
output_lines.push(format!(" {}", line));
}
}
}
output_lines.push(String::new());
}
let content = output_lines.join("\n");
if save {
let save_path = workdir.join(".gwtr-context.md");
std::fs::write(&save_path, &content)?;
println!("{}", format!("Saved to {:?}", save_path).green());
} else {
println!("{}", content);
}
Ok(())
}
fn collect_worktrees(repo: &Repository) -> Result<Vec<(String, String)>> {
use std::process::Command;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(workdir)
.output()
.context("Failed to execute git worktree list command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list worktrees: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let mut result = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with("worktree ") {
let path = lines[i].strip_prefix("worktree ").unwrap_or("").to_string();
let mut branch = String::from("detached");
if i + 1 < lines.len() && lines[i + 1].starts_with("HEAD ") {
i += 1;
}
if i + 1 < lines.len() && lines[i + 1].starts_with("branch ") {
branch = lines[i + 1]
.strip_prefix("branch refs/heads/")
.unwrap_or("unknown")
.to_string();
i += 1;
}
result.push((branch, path));
}
i += 1;
}
Ok(result)
}