Skip to main content

exeora_cli/
worktrees.rs

1use crate::config::{ConfigStore, ProjectEntry, WorktreeEntry, WorktreeSyncState};
2use anyhow::{Context, Result, anyhow, bail};
3use std::{
4    env, fs,
5    path::{Path, PathBuf},
6    process::Command,
7};
8use uuid::Uuid;
9
10pub struct CreateWorktree {
11    pub branch: String,
12    pub from: Option<String>,
13    pub reuse_existing_branch: bool,
14    pub name: Option<String>,
15    pub slug: Option<String>,
16    pub path: Option<PathBuf>,
17    /// Directory to run `git worktree add` from. Defaults to the current
18    /// checkout of this repository, then the registered project root.
19    pub source: Option<PathBuf>,
20}
21
22pub fn resolve_project(config: &ConfigStore, selector: Option<&str>) -> Result<ProjectEntry> {
23    if let Some(selector) = selector {
24        return config
25            .data()
26            .projects
27            .iter()
28            .find(|project| project.id == selector || project.slug.eq_ignore_ascii_case(selector))
29            .cloned()
30            .ok_or_else(|| anyhow!("No project called {selector} on this machine."));
31    }
32
33    let cwd = fs::canonicalize(env::current_dir()?)?;
34    let direct = config
35        .data()
36        .projects
37        .iter()
38        .filter_map(|project| {
39            fs::canonicalize(&project.root)
40                .ok()
41                .filter(|root| cwd.starts_with(root))
42                .map(|root| (root.components().count(), project.clone()))
43        })
44        .max_by_key(|(depth, _)| *depth)
45        .map(|(_, project)| project);
46    if let Some(project) = direct {
47        return Ok(project);
48    }
49    let worktree = config
50        .data()
51        .worktrees
52        .iter()
53        .filter_map(|entry| {
54            fs::canonicalize(&entry.root)
55                .ok()
56                .filter(|root| cwd.starts_with(root))
57                .map(|root| (root.components().count(), entry.project_id.as_str()))
58        })
59        .max_by_key(|(depth, _)| *depth)
60        .and_then(|(_, project_id)| config.find_project(project_id))
61        .cloned();
62    worktree.ok_or_else(|| {
63        anyhow!("The current directory is not inside an Exeora project. Pass --project <slug|id>.")
64    })
65}
66
67pub fn create(
68    config: &ConfigStore,
69    project: &ProjectEntry,
70    input: CreateWorktree,
71) -> Result<WorktreeEntry> {
72    let branch_slug = slugify(&input.branch);
73    let slug = input.slug.unwrap_or(branch_slug);
74    validate_slug(&slug)?;
75    ensure_unique(config, project, &slug, None)?;
76
77    let destination = input
78        .path
79        .unwrap_or(config.worktree_root()?.join(&project.slug).join(&slug));
80    let destination = absolute(destination)?;
81    validate_destination(config, &destination)?;
82    if destination.exists() {
83        bail!("{} already exists.", destination.display());
84    }
85    if let Some(parent) = destination.parent() {
86        fs::create_dir_all(parent)?;
87    }
88
89    let mut args = vec!["worktree".to_owned(), "add".to_owned()];
90    if input.reuse_existing_branch {
91        args.push(destination.to_string_lossy().into_owned());
92        args.push(input.branch.clone());
93    } else {
94        args.push("-b".to_owned());
95        args.push(input.branch.clone());
96        args.push(destination.to_string_lossy().into_owned());
97        args.push(input.from.unwrap_or_else(|| "HEAD".to_owned()));
98    }
99    let source = input
100        .source
101        .filter(|path| path.is_dir())
102        .or_else(|| current_repository_for(project))
103        .unwrap_or_else(|| project.root.clone());
104    git_checked(&source, &args)?;
105
106    match entry_for_path(
107        config,
108        project,
109        &destination,
110        input.name.unwrap_or_else(|| input.branch.clone()),
111        slug,
112        true,
113    ) {
114        Ok(entry) => Ok(entry),
115        Err(error) => {
116            let _ = git_checked(
117                &project.root,
118                &[
119                    "worktree".to_owned(),
120                    "remove".to_owned(),
121                    "--force".to_owned(),
122                    destination.to_string_lossy().into_owned(),
123                ],
124            );
125            Err(error)
126        }
127    }
128}
129
130fn current_repository_for(project: &ProjectEntry) -> Option<PathBuf> {
131    let cwd = env::current_dir().ok()?;
132    let current = git_path(&cwd, &["rev-parse", "--git-common-dir"]).ok()?;
133    let project_common = git_path(&project.root, &["rev-parse", "--git-common-dir"]).ok()?;
134    (fs::canonicalize(current).ok()? == fs::canonicalize(project_common).ok()?).then_some(cwd)
135}
136
137pub fn attach(
138    config: &ConfigStore,
139    project: &ProjectEntry,
140    path: &Path,
141    name: Option<String>,
142    slug: Option<String>,
143) -> Result<WorktreeEntry> {
144    let path = fs::canonicalize(path)
145        .with_context(|| format!("Could not open worktree {}", path.display()))?;
146    let branch =
147        git_optional(&path, &["branch", "--show-current"])?.filter(|value| !value.is_empty());
148    let fallback = branch
149        .as_deref()
150        .and_then(|value| value.rsplit('/').next())
151        .unwrap_or_else(|| {
152            path.file_name()
153                .and_then(|value| value.to_str())
154                .unwrap_or("worktree")
155        });
156    let slug = slug.unwrap_or_else(|| slugify(fallback));
157    validate_slug(&slug)?;
158    ensure_unique(config, project, &slug, None)?;
159    entry_for_path(
160        config,
161        project,
162        &path,
163        name.unwrap_or_else(|| fallback.to_owned()),
164        slug,
165        false,
166    )
167}
168
169fn entry_for_path(
170    config: &ConfigStore,
171    project: &ProjectEntry,
172    path: &Path,
173    name: String,
174    slug: String,
175    managed: bool,
176) -> Result<WorktreeEntry> {
177    let main_git_root = git_path(&project.root, &["rev-parse", "--show-toplevel"])?;
178    let worktree_git_root = git_path(path, &["rev-parse", "--show-toplevel"])?;
179    let main_git_root = fs::canonicalize(main_git_root)?;
180    let worktree_git_root = fs::canonicalize(worktree_git_root)?;
181    if main_git_root == worktree_git_root {
182        bail!("The primary project worktree is selected by `main` and cannot be attached again.");
183    }
184    if config
185        .data()
186        .worktrees
187        .iter()
188        .any(|entry| fs::canonicalize(&entry.git_root).ok().as_ref() == Some(&worktree_git_root))
189    {
190        bail!("That Git worktree is already connected to Exeora.");
191    }
192    let main_common = git_path(&project.root, &["rev-parse", "--git-common-dir"])?;
193    let worktree_common = git_path(path, &["rev-parse", "--git-common-dir"])?;
194    if fs::canonicalize(main_common)? != fs::canonicalize(worktree_common)? {
195        bail!(
196            "{} is not a worktree of the repository serving {}.",
197            path.display(),
198            project.slug
199        );
200    }
201    let relative_root = fs::canonicalize(&project.root)?
202        .strip_prefix(&main_git_root)
203        .context("The registered project root is outside its Git worktree")?
204        .to_path_buf();
205    let root = worktree_git_root.join(relative_root);
206    if !root.is_dir() {
207        bail!(
208            "The branch does not contain the registered project subdirectory {}.",
209            root.display()
210        );
211    }
212    let branch = git_optional(&worktree_git_root, &["branch", "--show-current"])?
213        .filter(|value| !value.is_empty());
214    Ok(WorktreeEntry {
215        id: format!("wtr_{}", Uuid::new_v4().simple()),
216        project_id: project.id.clone(),
217        slug,
218        name,
219        branch,
220        git_root: worktree_git_root,
221        root: fs::canonicalize(root)?,
222        managed,
223        sync_state: WorktreeSyncState::PendingUpsert,
224    })
225}
226
227pub fn ensure_unique(
228    config: &ConfigStore,
229    project: &ProjectEntry,
230    slug: &str,
231    except_id: Option<&str>,
232) -> Result<()> {
233    if slug.eq_ignore_ascii_case("main") {
234        bail!("`main` is reserved for the project's primary worktree.");
235    }
236    if config.data().worktrees.iter().any(|entry| {
237        entry.project_id == project.id
238            && entry.slug.eq_ignore_ascii_case(slug)
239            && except_id != Some(entry.id.as_str())
240    }) {
241        bail!("Worktree {slug} is already connected to {}.", project.slug);
242    }
243    Ok(())
244}
245
246pub fn validate_destination(config: &ConfigStore, destination: &Path) -> Result<()> {
247    let destination = normalized_nonexistent(destination)?;
248    for (kind, root) in config
249        .data()
250        .projects
251        .iter()
252        .map(|entry| ("project", &entry.root))
253        .chain(
254            config
255                .data()
256                .worktrees
257                .iter()
258                .map(|entry| ("worktree", &entry.git_root)),
259        )
260    {
261        if let Ok(root) = fs::canonicalize(root)
262            && destination.starts_with(&root)
263        {
264            bail!(
265                "Refusing to create a worktree inside the existing {kind} at {}.",
266                root.display()
267            );
268        }
269    }
270    Ok(())
271}
272
273pub fn is_dirty(entry: &WorktreeEntry) -> Result<bool> {
274    Ok(!git_output(&entry.git_root, &["status", "--porcelain"])?.is_empty())
275}
276
277pub fn remove_git_worktree(
278    project: &ProjectEntry,
279    entry: &WorktreeEntry,
280    force: bool,
281) -> Result<()> {
282    let mut args = vec!["worktree".to_owned(), "remove".to_owned()];
283    if force {
284        args.push("--force".to_owned());
285    }
286    args.push(entry.git_root.to_string_lossy().into_owned());
287    git_checked(&project.root, &args)
288}
289
290pub fn delete_branch(project: &ProjectEntry, branch: &str) -> Result<()> {
291    git_checked(
292        &project.root,
293        &["branch".to_owned(), "-d".to_owned(), branch.to_owned()],
294    )
295}
296
297fn validate_slug(slug: &str) -> Result<()> {
298    let valid = !slug.is_empty()
299        && slug.len() <= 60
300        && slug
301            .bytes()
302            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
303        && slug.as_bytes()[0].is_ascii_alphanumeric();
304    if !valid {
305        bail!("Worktree slugs use lowercase letters, digits and hyphens (max 60 characters).");
306    }
307    Ok(())
308}
309
310fn slugify(value: &str) -> String {
311    let mut result = String::new();
312    let mut hyphen = false;
313    for character in value.chars().flat_map(char::to_lowercase) {
314        if character.is_ascii_alphanumeric() {
315            result.push(character);
316            hyphen = false;
317        } else if !result.is_empty() && !hyphen {
318            result.push('-');
319            hyphen = true;
320        }
321        if result.len() >= 60 {
322            break;
323        }
324    }
325    while result.ends_with('-') {
326        result.pop();
327    }
328    if result.is_empty() {
329        "worktree".to_owned()
330    } else {
331        result
332    }
333}
334
335fn absolute(path: PathBuf) -> Result<PathBuf> {
336    Ok(if path.is_absolute() {
337        path
338    } else {
339        env::current_dir()?.join(path)
340    })
341}
342
343fn normalized_nonexistent(path: &Path) -> Result<PathBuf> {
344    if path.exists() {
345        return Ok(fs::canonicalize(path)?);
346    }
347    let parent = path
348        .parent()
349        .context("Worktree destination has no parent")?;
350    fs::create_dir_all(parent)?;
351    Ok(fs::canonicalize(parent)?.join(
352        path.file_name()
353            .context("Worktree destination has no name")?,
354    ))
355}
356
357fn git_path(cwd: &Path, args: &[&str]) -> Result<PathBuf> {
358    let value = git_output(cwd, args)?;
359    let path = PathBuf::from(value);
360    Ok(if path.is_absolute() {
361        path
362    } else {
363        cwd.join(path)
364    })
365}
366
367fn git_optional(cwd: &Path, args: &[&str]) -> Result<Option<String>> {
368    Ok(Some(git_output(cwd, args)?))
369}
370
371fn git_output(cwd: &Path, args: &[&str]) -> Result<String> {
372    let output = Command::new("git").arg("-C").arg(cwd).args(args).output()?;
373    if !output.status.success() {
374        bail!(
375            "git {} failed: {}",
376            args.join(" "),
377            String::from_utf8_lossy(&output.stderr).trim()
378        );
379    }
380    Ok(String::from_utf8(output.stdout)?.trim().to_owned())
381}
382
383fn git_checked(cwd: &Path, args: &[String]) -> Result<()> {
384    let output = Command::new("git").arg("-C").arg(cwd).args(args).output()?;
385    if !output.status.success() {
386        bail!(
387            "git {} failed: {}",
388            args.join(" "),
389            String::from_utf8_lossy(&output.stderr).trim()
390        );
391    }
392    Ok(())
393}
394
395#[cfg(test)]
396mod tests {
397    use super::{CreateWorktree, create, is_dirty, remove_git_worktree, slugify};
398    use crate::config::{ConfigStore, ProjectEntry, WorktreeSyncState};
399    use std::{fs, process::Command};
400    use tempfile::tempdir;
401
402    #[test]
403    fn creates_safe_slugs_from_branch_names() {
404        assert_eq!(slugify("feature/Worktrees!"), "feature-worktrees");
405    }
406
407    #[test]
408    fn creates_and_tracks_a_native_git_worktree() {
409        let temp = tempdir().expect("temp directory");
410        let repository = temp.path().join("repository");
411        fs::create_dir(&repository).expect("repository");
412        git(&repository, &["init"]);
413        git(&repository, &["config", "user.email", "test@example.com"]);
414        git(&repository, &["config", "user.name", "Exeora Test"]);
415        fs::write(repository.join("tracked.txt"), "main\n").expect("fixture");
416        git(&repository, &["add", "tracked.txt"]);
417        git(&repository, &["commit", "-m", "initial"]);
418
419        let mut config = ConfigStore::load_from(temp.path().join("config.json")).expect("config");
420        let project = ProjectEntry {
421            id: "prj_test".to_owned(),
422            slug: "repository".to_owned(),
423            name: "Repository".to_owned(),
424            root: fs::canonicalize(&repository).expect("root"),
425        };
426        config.upsert_project(project.clone());
427        let destination = temp.path().join("feature-worktree");
428
429        let entry = create(
430            &config,
431            &project,
432            CreateWorktree {
433                branch: "feature/worktrees".to_owned(),
434                from: None,
435                reuse_existing_branch: false,
436                name: None,
437                slug: None,
438                path: Some(destination),
439                source: None,
440            },
441        )
442        .expect("worktree");
443
444        assert_eq!(entry.slug, "feature-worktrees");
445        assert_eq!(entry.branch.as_deref(), Some("feature/worktrees"));
446        assert_eq!(entry.sync_state, WorktreeSyncState::PendingUpsert);
447        assert!(entry.root.join("tracked.txt").is_file());
448        assert!(!is_dirty(&entry).expect("status"));
449
450        remove_git_worktree(&project, &entry, false).expect("remove");
451        assert!(!entry.git_root.exists());
452    }
453
454    fn git(cwd: &std::path::Path, args: &[&str]) {
455        let status = Command::new("git")
456            .arg("-C")
457            .arg(cwd)
458            .args(args)
459            .status()
460            .expect("git");
461        assert!(status.success(), "git {}", args.join(" "));
462    }
463}