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 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}