//! Git worktrees for threads that start in one of their own: a fresh branch
//! checked out under the app's data folder, so the agent's work stays apart
//! from the project's checkout. Everything here blocks; call it from a
//! background task.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::git::git;
use crate::settings::{self, Submodules};
/// How to make a thread's worktree once its first message is sent.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorktreePlan {
/// The project folder the thread was started from.
pub project: PathBuf,
/// The branch to start from; `None` is whatever the project has checked out.
pub base: Option<String>,
pub from_origin: bool,
pub submodules: Submodules,
}
/// A thread's worktree.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Worktree {
/// The worktree's root.
pub root: PathBuf,
pub branch: String,
/// The main checkout it belongs to.
pub repo: PathBuf,
}
/// Creates the worktree and returns it with the folder the thread runs in
/// (the project's place inside the new checkout).
pub fn create(plan: &WorktreePlan, title: &str) -> Result<(Worktree, PathBuf), String> {
let repo = PathBuf::from(git(&plan.project, &["rev-parse", "--show-toplevel"])?.trim());
let inside = plan.project.strip_prefix(&repo).map(Path::to_path_buf).unwrap_or_default();
let project = repo.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| "project".into());
let base_dir = settings::data_dir().ok_or("no data folder")?.join("worktrees").join(&project);
let slug = slug(title);
let mut branch = format!("vectrum/{slug}");
let mut root = base_dir.join(&slug);
for n in 2.. {
let taken = git(&repo, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]).is_ok();
if !taken && !root.exists() {
break;
}
branch = format!("vectrum/{slug}-{n}");
root = base_dir.join(format!("{slug}-{n}"));
}
let mut base = plan.base.clone().unwrap_or_else(|| "HEAD".into());
if plan.from_origin && base != "HEAD" {
// The newest commit origin has for the branch, if origin has it.
if git(&repo, &["fetch", "--quiet", "origin", &base]).is_ok()
&& git(&repo, &["rev-parse", "--verify", "-q", &format!("refs/remotes/origin/{base}")]).is_ok()
{
base = format!("origin/{base}");
}
}
std::fs::create_dir_all(&base_dir).map_err(|err| err.to_string())?;
let root_arg = root.to_string_lossy().to_string();
git(&repo, &["worktree", "add", "--no-track", "-b", &branch, &root_arg, &base])?;
let submodules: &[&str] = match plan.submodules {
Submodules::Skip => &[],
Submodules::TopLevel => &["submodule", "update", "--init"],
Submodules::Recursive => &["submodule", "update", "--init", "--recursive"],
};
if !submodules.is_empty() && root.join(".gitmodules").exists() {
git(&root, submodules).map_err(|err| format!("couldn't fetch submodules: {err}"))?;
}
let cwd = root.join(inside);
Ok((Worktree { root, branch, repo }, cwd))
}
/// Removes a worktree whose thread is gone. Git refuses while it holds
/// uncommitted changes, which keeps them; the branch always stays.
pub fn remove(worktree: &Worktree) -> Result<(), String> {
let root = worktree.root.to_string_lossy().to_string();
git(&worktree.repo, &["worktree", "remove", &root]).map(|_| ())
}
/// A pull request opened from a thread's branch.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PullRequest {
pub number: u64,
pub url: String,
/// `OPEN`, `MERGED` or `CLOSED`.
pub state: String,
}
impl PullRequest {
pub fn is_open(&self) -> bool {
self.state == "OPEN"
}
}
/// The pull request for `branch`, asking GitHub's `gh` CLI. `None` without
/// `gh`, without a pull request, or when GitHub can't be reached.
pub fn pull_request(repo: &Path, branch: &str) -> Option<PullRequest> {
let gh = crate::ui::workspace::find_program(&["gh"])?;
let output = std::process::Command::new(gh)
.args(["pr", "view", branch, "--json", "number,url,state"])
.current_dir(repo)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value: Value = serde_json::from_slice(&output.stdout).ok()?;
Some(PullRequest {
number: value.get("number")?.as_u64()?,
url: value.get("url")?.as_str()?.to_string(),
state: value.get("state")?.as_str()?.to_string(),
})
}
/// A branch- and folder-safe name from a thread title: `Fix the login bug!`
/// → `fix-the-login-bug`.
pub fn slug(title: &str) -> String {
let mut slug = String::new();
for c in title.chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
slug.push(c);
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
if slug.len() >= 40 {
break;
}
}
let slug = slug.trim_end_matches('-').to_string();
if slug.is_empty() { "thread".into() } else { slug }
}
#[cfg(test)]
mod tests {
use super::{WorktreePlan, create, remove, slug};
use crate::git::git;
use crate::settings::Submodules;
#[::core::prelude::v1::test]
fn creates_and_removes_worktrees() {
let root = std::env::temp_dir().join(format!("vectrum-worktree-test-{}", std::process::id()));
let repo = root.join("repo");
std::fs::create_dir_all(repo.join("sub")).unwrap();
// Worktrees go under the data folder; keep this test's apart.
unsafe { std::env::set_var("XDG_DATA_HOME", root.join("data")) };
git(&repo, &["init", "-q", "-b", "main"]).unwrap();
std::fs::write(repo.join("sub/file.txt"), "hello\n").unwrap();
git(&repo, &["add", "."]).unwrap();
git(&repo, &["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"]).unwrap();
let plan = WorktreePlan {
project: repo.join("sub"),
base: Some("main".into()),
from_origin: true,
submodules: Submodules::Recursive,
};
let (worktree, cwd) = create(&plan, "Fix the thing").unwrap();
assert_eq!(worktree.branch, "vectrum/fix-the-thing");
assert!(cwd.ends_with("fix-the-thing/sub"));
assert_eq!(std::fs::read_to_string(cwd.join("file.txt")).unwrap(), "hello\n");
// A second thread with the same title gets its own branch.
let (second, _) = create(&plan, "Fix the thing").unwrap();
assert_eq!(second.branch, "vectrum/fix-the-thing-2");
remove(&worktree).unwrap();
assert!(!worktree.root.exists());
let _ = remove(&second);
let _ = std::fs::remove_dir_all(&root);
}
#[::core::prelude::v1::test]
fn slugs() {
assert_eq!(slug("Fix the login bug!"), "fix-the-login-bug");
assert_eq!(slug(" ¿Qué? "), "qu");
assert_eq!(slug("…"), "thread");
assert!(slug(&"word ".repeat(30)).len() <= 40);
}
}