From b8d5a814369e1ad0f328f1165342bc23100155a5 Mon Sep 17 00:00:00 2001 From: Noah Knegt Date: Wed, 7 May 2025 18:59:03 +0200 Subject: [PATCH] Create rust bins --- .gitignore | 2 +- Cargo.toml | 6 + create-worktree/Cargo.toml | 14 +++ create-worktree/src/main.rs | 231 ++++++++++++++++++++++++++++++++++++ setup-repo/Cargo.toml | 13 ++ setup-repo/src/main.rs | 168 ++++++++++++++++++++++++++ 6 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 Cargo.toml create mode 100644 create-worktree/Cargo.toml create mode 100644 create-worktree/src/main.rs create mode 100644 setup-repo/Cargo.toml create mode 100644 setup-repo/src/main.rs diff --git a/.gitignore b/.gitignore index ab951f8..2f95cf9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +# Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..32699d2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +resolver = "3" +members = [ + "create-worktree", + "setup-repo", +] diff --git a/create-worktree/Cargo.toml b/create-worktree/Cargo.toml new file mode 100644 index 0000000..f485f19 --- /dev/null +++ b/create-worktree/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "create-worktree" +version = "0.1.0" +edition = "2024" +authors = ["Noah Knegt "] +description = "A tool to create git worktrees with convenient branch management" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +anyhow = "1.0" +colored = "3.0" +indicatif = "0.17" +dirs = "6.0" +thiserror = { version = "2.0" } diff --git a/create-worktree/src/main.rs b/create-worktree/src/main.rs new file mode 100644 index 0000000..c299158 --- /dev/null +++ b/create-worktree/src/main.rs @@ -0,0 +1,231 @@ +use std::env; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use clap::{ArgAction, Parser}; +use colored::*; +use indicatif::{ProgressBar, ProgressStyle}; + +/// Tool to create git worktrees with convenient branch management +#[derive(Parser, Debug)] +#[command(author, version, about)] +struct Args { + /// Path for the new worktree + worktree: String, + + /// Branch to create + #[arg(short, long)] + branch: Option, + + /// Base branch to use for the new worktree + #[arg(short = 'B', long, default_value = "origin/main")] + base: String, + + /// Prefix to apply to the branch name + #[arg(short, long, default_value = "")] + prefix: String, + + /// Do not create an upstream branch + #[arg(short = 'N', long, action = ArgAction::SetFalse)] + no_create_upstream: bool, + + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + + /// Disable colored output + #[arg(long)] + no_color: bool, +} + +/// Git operations error type +#[derive(Debug, thiserror::Error)] +enum GitError { + #[error("Command failed with exit code: {0}")] + Failed(i32), + + #[error("Command failed without exit code")] + FailedNoCode, + + #[error("Failed to execute command: {0}")] + ExecutionError(#[from] std::io::Error), +} + +/// Runs a git command with a progress spinner +fn run_command(command: &mut Command, message: &str) -> Result<()> { + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .tick_chars("⣾⣽⣻⢿⡿⣟⣯⣷") + .template("{spinner:.green} {msg}") + .expect("Invalid template format"), + ); + spinner.set_message(message.to_string()); + + // Configure the command to not show output + command.stdout(Stdio::null()).stderr(Stdio::null()); + + // Execute the command and wait for it to complete + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + + let status = command.status().context("Failed to execute command")?; + spinner.finish_and_clear(); + + if status.success() { + println!("{message} {}", "Done.".green()); + Ok(()) + } else { + println!("{message} {}", "FAILED.".red()); + + let code = status.code(); + match code { + Some(code) => Err(GitError::Failed(code).into()), + None => Err(GitError::FailedNoCode.into()), + } + } +} + +/// Git command wrapper +struct Git; + +impl Git { + /// Check if a branch exists locally + fn branch_exists_locally(branch: &str) -> Result { + let output = Command::new("git") + .args([ + "branch", + "--list", + branch + ]) + .output() + .context("Failed to check branch existence")?; + + Ok(!output.stdout.is_empty()) + } + + /// Check if a branch exists on the remote + fn branch_exists_on_remote(branch: &str) -> Result { + let output = Command::new("git") + .args([ + "ls-remote", + "--heads", + "origin", + branch + ]) + .output() + .context("Failed to check remote branch existence")?; + + Ok(!output.stdout.is_empty()) + } + + /// Create a new worktree with an existing branch + fn create_worktree_existing_branch(worktree_path: &str, branch: &str) -> Result<()> { + let mut cmd = Command::new("git"); + cmd.args([ + "worktree", + "add", + worktree_path, + branch + ]); + run_command(&mut cmd, &format!("Generating new worktree from existing branch: {branch}")) + } + + /// Create a new worktree with a new branch + fn create_worktree_new_branch(worktree_path: &str, branch: &str, base: &str) -> Result<()> { + let mut cmd = Command::new("git"); + cmd.args([ + "worktree", + "add", + "-b", branch, + worktree_path, + base + ]); + run_command(&mut cmd, &format!("Generating new worktree: {worktree_path}")) + } + + /// Create and push a new remote branch + fn create_remote_branch(branch: &str) -> Result<()> { + let mut cmd = Command::new("git"); + cmd.args([ + "push", + "-u", "origin", + branch + ]); + run_command(&mut cmd, &format!("Creating remote branch {branch}...")) + } + + /// Set the upstream branch + fn set_upstream_branch(branch: &str) -> Result<()> { + let mut cmd = Command::new("git"); + cmd.args([ + "branch", + "--set-upstream-to", &format!("origin/{branch}") + ]); + run_command(&mut cmd, &format!("Setting upstream branch to 'origin/{branch}'")) + } +} + +/// Update or create the remote tracking branch +fn update_remote(branch: &str, create_upstream: bool) -> Result<()> { + // Do nothing if create_upstream is disabled + if !create_upstream { + return Ok(()); + } + + // Check if branch exists on remote + let has_remote = Git::branch_exists_on_remote(branch)?; + + if !has_remote { + // Create remote branch + println!("{}", format!("Branch '{branch}' does not exist on remote. Creating.").dimmed()); + Git::set_upstream_branch(branch)?; + // Git::create_remote_branch(branch)?; + } else { + println!("{}", format!("Branch '{branch}' exists. Setting upstream.").dimmed()); + Git::set_upstream_branch(branch)?; + } + + Ok(()) +} + +fn main() -> Result<()> { + // Parse arguments + let args = Args::parse(); + + // Enable or disable colored output + colored::control::set_override(!args.no_color); + + // Print verbose information if enabled + if args.verbose { + println!("{}", "Verbose mode enabled".dimmed()); + println!("{}", format!("Base branch: {}", args.base).dimmed()); + println!("{}", format!("Create upstream: {}", args.no_create_upstream).dimmed()); + } + + // Determine branch name if not specified + let branch = args.branch.unwrap_or_else(|| format!("{}{}", args.prefix, args.worktree)); + + // Normalize paths + let worktree_path = Path::new(&args.worktree).to_string_lossy(); + + // Check if branch already exists + let branch_exists = Git::branch_exists_locally(&branch)?; + + // Create the worktree + if branch_exists { + Git::create_worktree_existing_branch(&args.worktree, &branch)?; + } else { + Git::create_worktree_new_branch(&args.worktree, &branch, &args.base)?; + } + + // Change to worktree directory + println!("{}", format!("Moving into worktree: {worktree_path}").dimmed()); + env::set_current_dir(&args.worktree).context("Failed to change directory")?; + + // Update remote + update_remote(&branch, args.no_create_upstream)?; + + println!("{}", "Success.".green()); + Ok(()) +} diff --git a/setup-repo/Cargo.toml b/setup-repo/Cargo.toml new file mode 100644 index 0000000..efeb33b --- /dev/null +++ b/setup-repo/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "setup-repo" +version = "0.1.0" +edition = "2024" +description = "Tool to set up Git repositories for worktree development" +authors = ["Noah Knegt "] + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +colored = "3.0" +indicatif = "0.17" +thiserror = "2.0" diff --git a/setup-repo/src/main.rs b/setup-repo/src/main.rs new file mode 100644 index 0000000..5df8572 --- /dev/null +++ b/setup-repo/src/main.rs @@ -0,0 +1,168 @@ +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use clap::Parser; +use colored::*; +use indicatif::{ProgressBar, ProgressStyle}; + +/// Tool to set up Git repositories for worktree development +#[derive(Parser, Debug)] +#[command(author, version, about)] +struct Args { + /// Repository URL to clone + #[arg(short, long)] + repo_url: String, + + /// Target directory for the repository setup + #[arg(short, long)] + target_dir: String, + + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + + /// Disable colored output + #[arg(long)] + no_color: bool, +} + +/// Git operations error type +#[derive(Debug, thiserror::Error)] +enum GitError { + #[error("Command failed with exit code: {0}")] + Failed(i32), + + #[error("Command failed without exit code")] + FailedNoCode, + + #[error("Failed to execute command: {0}")] + ExecutionError(#[from] std::io::Error), +} + +/// Runs a command with a progress spinner +fn run_command(command: &mut Command, message: &str) -> Result<()> { + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .tick_chars("⣾⣽⣻⢿⡿⣟⣯⣷") + .template("{spinner:.green} {msg}") + .expect("Invalid template format"), + ); + spinner.set_message(message.to_string()); + + // Configure the command to not show output + command.stdout(Stdio::null()).stderr(Stdio::null()); + + // Execute the command and wait for it to complete + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + + let status = command.status().context("Failed to execute command")?; + spinner.finish_and_clear(); + + if status.success() { + println!("{message} {}", "Done.".green()); + Ok(()) + } else { + println!("{message} {}", "FAILED.".red()); + + let code = status.code(); + match code { + Some(code) => Err(GitError::Failed(code).into()), + None => Err(GitError::FailedNoCode.into()), + } + } +} + +/// Git command wrapper +struct Git; + +impl Git { + /// Clone a repository as a bare clone in a .bare directory + fn clone_bare_repo(repo_url: &str, target_dir: &str) -> Result<()> { + // Create the base directory first + std::fs::create_dir_all(target_dir).context("Failed to create target directory")?; + + // Create the .bare subdirectory path + let bare_dir = Path::new(target_dir).join(".bare"); + let bare_dir_str = bare_dir.to_string_lossy(); + + // Clone the repository as a bare clone into .bare directory + let mut cmd = Command::new("git"); + cmd.args([ + "clone", + "--bare", + repo_url, + &bare_dir_str + ]); + run_command(&mut cmd, &format!("Cloning repository as bare clone into {bare_dir_str}")) + } + + /// Set up the .git file to point to the .bare directory + fn setup_git_pointer(target_dir: &str) -> Result<()> { + let git_file_path = Path::new(target_dir).join(".git"); + std::fs::write(git_file_path, "gitdir: ./.bare") + .context("Failed to create .git file pointing to .bare directory") + } + + /// Configure remote.origin.fetch to fetch all references + fn configure_remote_fetch(target_dir: &str) -> Result<()> { + let bare_dir = Path::new(target_dir).join(".bare"); + let bare_dir_str = bare_dir.to_string_lossy(); + + let mut cmd = Command::new("git"); + cmd.args([ + "--git-dir", &bare_dir_str, + "config", + "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*" + ]); + run_command(&mut cmd, "Configuring remote.origin.fetch") + } + + /// Fetch all remotes + fn fetch_remotes(target_dir: &str) -> Result<()> { + let bare_dir = Path::new(target_dir).join(".bare"); + let bare_dir_str = bare_dir.to_string_lossy(); + + let mut cmd = Command::new("git"); + cmd.args([ + "--git-dir", &bare_dir_str, + "fetch", + "--all" + ]); + run_command(&mut cmd, "Fetching all remotes") + } +} + +fn main() -> Result<()> { + // Parse arguments + let args = Args::parse(); + + // Enable or disable colored output + colored::control::set_override(!args.no_color); + + // Print verbose information if enabled + if args.verbose { + println!("{}", "Verbose mode enabled".dimmed()); + println!("{}", format!("Repository URL: {}", args.repo_url).dimmed()); + println!("{}", format!("Target directory: {}", args.target_dir).dimmed()); + } + + println!("{}", "Setting up repository for worktree development".blue()); + + // Clone the repository as a bare clone + Git::clone_bare_repo(&args.repo_url, &args.target_dir)?; + + // Set up the .git file to point to the .bare directory + Git::setup_git_pointer(&args.target_dir)?; + + // Configure the remote.origin.fetch setting + Git::configure_remote_fetch(&args.target_dir)?; + + // Fetch all remotes + Git::fetch_remotes(&args.target_dir)?; + + println!("{}", "Repository setup complete.".green()); + println!("{}", format!("You can now create worktrees in '{}'.", args.target_dir).green()); + Ok(()) +}