Simple Git Worktree Manager in Rust

A minimal clone of Worktrunk for beginners

Welcome to another official UVF IT guide! In this short tutorial we’ll build a tiny Git worktree manager inspired by the Worktrunk CLI, using only Rust’s standard library and the free git2 crate. The goal is a working prototype you can run locally in about 45 minutes.

You’ll learn how to scaffold a Cargo project, model worktree data, expose a tiny HTTP API with warp, and test the basic commands on your own repository.

Before you start

Component Overview
LOCAL ENVIRONMENT git2 calls stores state Rust CLI + HTTP Server Git Repository In‑memory worktree list
1

Phase 1: Scaffold the project

Create a new Cargo binary and add the required dependencies. This gives us a clean project layout and pulls in git2 for talking to Git and warp for a tiny HTTP API, both of which are essential later on.

cargo new worktrunk_clone --bin
cd worktrunk_clone
# Add dependencies to Cargo.toml
cat >> Cargo.toml <<'EOF'
[dependencies]
git2 = "0.18"
warp = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
EOF
2

Phase 2: Model worktree data

Define a simple Worktree struct and an in‑memory vector to hold them. This mirrors the minimal state the real tool tracks – a name, its path on disk, and the branch it checks out. Keeping the data in a Vec lets us focus on the core logic without a database.

src/main.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Worktree {
    name: String,
    path: String,
    branch: String,
}

impl Worktree {
    fn new(name: &str, path: &str, branch: &str) -> Self {
        Self {
            name: name.to_string(),
            path: path.to_string(),
            branch: branch.to_string(),
        }
    }
}

// In‑memory store – a simple Vec wrapped in a mutex for thread safety.
use std::sync::{Arc, Mutex};
type Store = Arc<Mutex<Vec<Worktree>>>;

fn init_store() -> Store {
    Arc::new(Mutex::new(Vec::new()))
}

#[tokio::main]
async fn main() {
    let _store = init_store();
    println!("Worktree manager scaffold ready – next phase will add HTTP routes.");
}
3

Phase 3: Core Git operations

In this step we wrap the low‑level git2 library so our program can talk to Git without spawning external commands. We'll expose three tiny helpers: one to list existing worktrees, one to add a new worktree, and one to remove a worktree. Errors are caught and printed to the console, keeping the UX simple for beginners. These helpers will be called later by our HTTP layer, so they return plain Result values that the server can translate into HTTP responses.

src/main.rs

use git2::{Repository, Worktree, Error};
use std::path::Path;

pub fn list_worktrees(repo_path: &str) -> Result<Vec<String>, Error> {
    let repo = Repository::open(repo_path)?;
    let mut names = Vec::new();
    for wt in repo.worktrees()? {
        names.push(wt.path().to_string_lossy().into_owned());
    }
    Ok(names)
}

pub fn add_worktree(repo_path: &str, path: &str, branch: &str) -> Result<(), Error> {
    let repo = Repository::open(repo_path)?;
    let mut wt = repo.worktree(path, branch, None)?;
    wt.lock()?; // ensure the worktree is usable
    Ok(())
}

pub fn remove_worktree(repo_path: &str, path: &str) -> Result<(), Error> {
    let repo = Repository::open(repo_path)?;
    let mut wt = repo.worktree(path, "", None)?; // empty branch just to get handle
    wt.unlock()?;
    wt.remove()?;
    Ok(())
}
4

Phase 4: Tiny HTTP API

Now we expose the Git helpers through a minimal HTTP API built with warp. Three routes are provided: /list returns a JSON array of worktree paths, /add creates a new worktree from query parameters path and branch, and /remove deletes a worktree identified by path. Each handler forwards to the functions from Phase 3 and maps success or failure to appropriate HTTP status codes.

src/main.rs

use warp::Filter;
use serde_json::json;

mod git_helpers; // assumes the functions above are in this module

#[tokio::main]
async fn main() {
    let repo_path = std::env::var("REPO_PATH").unwrap_or_else(|_| ".".into());

    let list = warp::path("list")
        .and(warp::get())
        .and_then(move || {
            let rp = repo_path.clone();
            async move {
                match git_helpers::list_worktrees(&rp) {
                    Ok(wts) => Ok(warp::reply::json(&wts)),
                    Err(e) => Ok(warp::reply::with_status(
                        json!({"error": e.to_string()}),
                        warp::http::StatusCode::INTERNAL_SERVER_ERROR,
                    )),
                }
            }
        });

    let add = warp::path("add")
        .and(warp::get())
        .and(warp::query::<std::collections::HashMap<String, String>>())
        .and_then(move |q| {
            let rp = repo_path.clone();
            async move {
                let path = q.get("path").map(|s| s.as_str()).unwrap_or("");
                let branch = q.get("branch").map(|s| s.as_str()).unwrap_or("master");
                match git_helpers::add_worktree(&rp, path, branch) {
                    Ok(_) => Ok(warp::reply::with_status("added", warp::http::StatusCode::OK)),
                    Err(e) => Ok(warp::reply::with_status(e.to_string(), warp::http::StatusCode::BAD_REQUEST)),
                }
            }
        });

    let remove = warp::path("remove")
        .and(warp::get())
        .and(warp::query::<std::collections::HashMap<String, String>>())
        .and_then(move |q| {
            let rp = repo_path.clone();
            async move {
                let path = q.get("path").map(|s| s.as_str()).unwrap_or("");
                match git_helpers::remove_worktree(&rp, path) {
                    Ok(_) => Ok(warp::reply::with_status("removed", warp::http::StatusCode::OK)),
                    Err(e) => Ok(warp::reply::with_status(e.to_string(), warp::http::StatusCode::BAD_REQUEST)),
                }
            }
        });

    let routes = list.or(add).or(remove);
    println!("Server listening on http://0.0.0.0:3030");
    warp::serve(routes).run(([0, 0, 0, 0], 3030)).await;
}
5

Phase 5: Run and verify

With the code compiled, start the server and point it at a real Git repository using the REPO_PATH environment variable. Then use curl to hit each endpoint: list the current worktrees, add a new one, and finally remove it. Watching the repository's .git/worktrees directory confirms that the operations really happened on disk.

# Build the project (once)
cargo build --release

# Run the server, pointing at the current repo (or set REPO_PATH to another path)
REPO_PATH=$(pwd) cargo run --release &
SERVER_PID=$!

# Give the server a moment to start
sleep 2

# 1️⃣ List worktrees (should be empty initially)
curl http://localhost:3030/list

# 2️⃣ Add a new worktree named "feature-wt" on branch "feature"
curl "http://localhost:3030/add?path=feature-wt&branch=feature"

# 3️⃣ Verify it appears in the list
curl http://localhost:3030/list

# 4️⃣ Remove the worktree again
curl "http://localhost:3030/remove?path=feature-wt"

# 5️⃣ Clean up the server process
kill $SERVER_PID

Verifying the Stack

CheckCommand / Why it matters
Project builds without errorscargo build
Server starts and listens on port 3030curl -I http://localhost:3030
/list returns a JSON array (empty or with entries)curl http://localhost:3030/list

What’s next?

Try persisting worktrees to a tiny SQLite file with rusqlite, add authentication to the API, or expand the CLI with subcommands using clap. Happy hacking!