1use std::{
19 path::{Component, Path, PathBuf},
20 process::Stdio,
21 time::Duration,
22};
23
24use scv_core::ToolError;
25use scv_protocol::{WorktreeInfo, WorktreeRef};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use tokio::process::Command;
29use tokio_util::sync::CancellationToken;
30
31const GIT_TIMEOUT: Duration = Duration::from_secs(30);
33const MAX_GIT_OUTPUT: usize = 64 * 1024;
35const MAX_RECORD_BYTES: u64 = 16 * 1024;
37const NAME_DRAWS: usize = 8;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Created {
43 pub worktree: WorktreeRef,
44 pub cwd: PathBuf,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
50struct Record {
51 v: u32,
52 source: PathBuf,
55 branch: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 common: Option<PathBuf>,
60}
61
62struct Linked {
64 top: PathBuf,
65 git_dir: PathBuf,
67 common: PathBuf,
71 source: PathBuf,
73}
74
75pub(crate) async fn create(
80 root: &Path,
81 cwd: &Path,
82 workspace: &Path,
83 agent: &str,
84 cancellation: &CancellationToken,
85) -> Result<Created, ToolError> {
86 let top = git(cwd, &["rev-parse", "--show-toplevel"], cancellation)
87 .await
88 .map_err(|error| {
89 ToolError::invalid_arguments(format!(
90 "worktree needs cwd to be inside a git repository: {error}"
91 ))
92 })?;
93 let source = std::fs::canonicalize(top.trim())
94 .map_err(|error| ToolError::failed(format!("resolve the repository: {error}")))?;
95 if !source.starts_with(workspace) {
96 return Err(ToolError::invalid_arguments(format!(
97 "worktree needs the repository holding cwd ({}) to be inside the workspace; \
98 cwd is in a repository that reaches outside it",
99 source.display()
100 )));
101 }
102 let inside = cwd
103 .strip_prefix(&source)
104 .unwrap_or(Path::new(""))
105 .to_owned();
106 let common = common_dir(&source)
107 .and_then(|common| std::fs::canonicalize(common).ok())
108 .ok_or_else(|| ToolError::failed("resolve the repository's git directory"))?;
109 let group = root.join(group_name(&source));
110 private_dir(root)
111 .and_then(|()| private_dir(&group))
112 .map_err(|error| ToolError::failed(format!("create the worktrees directory: {error}")))?;
113 let mut reserved = None;
116 for _ in 0..NAME_DRAWS {
117 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..6];
118 let name = format!("{}-{suffix}", slug(agent));
119 let record = Record {
120 v: 1,
121 source: source.clone(),
122 branch: format!("scv/{name}"),
123 common: Some(common.clone()),
124 };
125 match reserve(&group, &name, &record) {
126 Ok(()) => {
127 reserved = Some((name, record.branch));
128 break;
129 }
130 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
131 Err(error) => {
132 return Err(ToolError::failed(format!("record the worktree: {error}")));
133 }
134 }
135 }
136 let (name, branch) =
137 reserved.ok_or_else(|| ToolError::failed("no free worktree name; try again"))?;
138 let path = group.join(&name);
139 let record = group.join(format!("{name}.json"));
140 let target = path.display().to_string();
141 let added = git(
142 &source,
143 &["worktree", "add", "-b", &branch, &target, "HEAD"],
144 cancellation,
145 )
146 .await;
147 if let Err(error) = added {
148 discard(&source, &path, &record).await;
149 return Err(ToolError::failed(format!(
150 "git worktree add failed: {error}"
151 )));
152 }
153 let path = std::fs::canonicalize(&path).unwrap_or(path);
154 let cwd = Some(&inside)
156 .filter(|inside| !inside.as_os_str().is_empty())
157 .map(|inside| path.join(inside))
158 .filter(|candidate| candidate.is_dir())
159 .unwrap_or_else(|| path.clone());
160 Ok(Created {
161 worktree: WorktreeRef {
162 path: path.display().to_string(),
163 branch,
164 repo: source.display().to_string(),
165 },
166 cwd,
167 })
168}
169
170async fn discard(source: &Path, path: &Path, record: &Path) {
173 let never = CancellationToken::new();
174 if path.exists() {
175 let target = path.display().to_string();
176 let _ = git(
177 source,
178 &["worktree", "remove", "--force", "--force", &target],
179 &never,
180 )
181 .await;
182 }
183 let _ = git(source, &["worktree", "prune"], &never).await;
184 let _ = std::fs::remove_file(record);
185}
186
187pub(crate) fn worktree_path(root: &Path, path: &Path) -> Option<PathBuf> {
190 worktree_top(root, path).map(|linked| linked.top)
191}
192
193pub fn worktree_repo(root: &Path, path: &Path) -> Option<PathBuf> {
196 worktree_top(root, path).map(|linked| linked.source)
197}
198
199pub(crate) fn worktree_ref(root: &Path, path: &Path) -> Option<WorktreeRef> {
202 let (top, record) = recorded(root, path)?;
203 verified_git_dir(&top, &record)?;
204 Some(WorktreeRef {
205 path: top.display().to_string(),
206 branch: record.branch,
207 repo: record.source.display().to_string(),
208 })
209}
210
211fn worktree_top(root: &Path, path: &Path) -> Option<Linked> {
214 let (top, record) = recorded(root, path)?;
215 let (git_dir, common) = verified_git_dir(&top, &record)?;
216 Some(Linked {
217 top,
218 git_dir,
219 common,
220 source: record.source,
221 })
222}
223
224fn recorded(root: &Path, path: &Path) -> Option<(PathBuf, Record)> {
228 let root = std::fs::canonicalize(root).ok()?;
229 let path = std::fs::canonicalize(path).ok()?;
230 let relative = path.strip_prefix(&root).ok()?;
231 let mut parts = relative.components();
232 let (Some(Component::Normal(group)), Some(Component::Normal(name))) =
233 (parts.next(), parts.next())
234 else {
235 return None;
236 };
237 let top = root.join(group).join(name);
238 let mut record_name = name.to_os_string();
239 record_name.push(".json");
240 let record = read_record(&root.join(group).join(record_name))?;
241 Some((top, record))
242}
243
244fn verified_git_dir(top: &Path, record: &Record) -> Option<(PathBuf, PathBuf)> {
251 let common = record
252 .common
253 .clone()
254 .or_else(|| common_dir(&record.source))?;
255 let common = std::fs::canonicalize(common).ok()?;
256 let git_dir = common.join("worktrees").join(top.file_name()?);
257 let canonical = |base: &Path, link: &str| std::fs::canonicalize(base.join(link.trim())).ok();
259 let pointer = read_small(&top.join(".git"))?;
260 let named = canonical(top, pointer.trim().strip_prefix("gitdir:")?)?;
261 let back = canonical(&git_dir, &read_small(&git_dir.join("gitdir"))?)?;
262 let shared = canonical(&git_dir, &read_small(&git_dir.join("commondir"))?)?;
263 (named == git_dir && back == top.join(".git") && shared == common).then_some((git_dir, common))
264}
265
266fn common_dir(checkout: &Path) -> Option<PathBuf> {
270 let dot_git = checkout.join(".git");
271 if dot_git.is_dir() {
272 return Some(dot_git);
273 }
274 let pointer = read_small(&dot_git)?;
275 let git_dir = checkout.join(pointer.trim().strip_prefix("gitdir:")?.trim());
276 Some(match read_small(&git_dir.join("commondir")) {
277 Some(common) => git_dir.join(common.trim()),
278 None => git_dir,
279 })
280}
281
282fn read_small(path: &Path) -> Option<String> {
284 use std::io::Read as _;
285 let file = std::fs::File::open(path).ok()?;
286 let mut text = String::new();
287 file.take(MAX_RECORD_BYTES).read_to_string(&mut text).ok()?;
288 Some(text)
289}
290
291pub(crate) fn in_workspace(root: &Path, path: &Path, workspace: &Path) -> bool {
294 worktree_repo(root, path).is_some_and(|source| source.starts_with(workspace))
295}
296
297pub async fn list(root: &Path) -> Vec<WorktreeInfo> {
300 let mut found = Vec::new();
301 let Ok(root) = std::fs::canonicalize(root) else {
303 return found;
304 };
305 let root = root.as_path();
306 let Ok(groups) = std::fs::read_dir(root) else {
307 return found;
308 };
309 let never = CancellationToken::new();
310 for group in groups.filter_map(Result::ok) {
311 let Ok(names) = std::fs::read_dir(group.path()) else {
312 continue;
313 };
314 for name in names.filter_map(Result::ok) {
315 let path = name.path();
316 if !name.file_type().is_ok_and(|kind| kind.is_dir()) {
317 continue;
318 }
319 let Some((top, record)) = recorded(root, &path) else {
320 continue;
321 };
322 let (branch, dirty) = match verified_git_dir(&top, &record) {
325 Some((git_dir, common)) => {
326 let linked = Linked {
327 top,
328 git_dir,
329 common,
330 source: record.source.clone(),
331 };
332 let branch =
333 git_in_worktree(&linked, &["rev-parse", "--abbrev-ref", "HEAD"], &never)
334 .await
335 .map(|branch| branch.trim().to_owned())
336 .unwrap_or_default();
337 let dirty = git_in_worktree(
342 &linked,
343 &["status", "--porcelain", "--ignore-submodules=all"],
344 &never,
345 )
346 .await
347 .ok()
348 .map(|status| !status.trim().is_empty());
349 (branch, dirty)
350 }
351 None => (record.branch, None),
352 };
353 found.push(WorktreeInfo {
354 worktree: WorktreeRef {
355 path: path.display().to_string(),
356 branch,
357 repo: record.source.display().to_string(),
358 },
359 dirty,
360 jobs: Vec::new(),
361 });
362 }
363 }
364 found.sort_by(|a, b| a.worktree.path.cmp(&b.worktree.path));
365 found
366}
367
368pub async fn remove(root: &Path, path: &Path, force: bool) -> Result<String, String> {
372 let (top, record) = recorded(root, path)
373 .ok_or_else(|| format!("{} is not a worktree SCV created", path.display()))?;
374 let (git_dir, common) = verified_git_dir(&top, &record).ok_or_else(|| {
375 format!(
376 "{} is not linked to the repository it was made from ({}) as SCV made it, so SCV \
377 runs no git in it; check its .git file, and remove it yourself",
378 top.display(),
379 record.source.display()
380 )
381 })?;
382 let linked = Linked {
383 top,
384 git_dir,
385 common,
386 source: record.source,
387 };
388 let never = CancellationToken::new();
389 let branch = git_in_worktree(&linked, &["rev-parse", "--abbrev-ref", "HEAD"], &never)
390 .await
391 .map(|branch| branch.trim().to_owned())
392 .unwrap_or_default();
393 let path = linked.top.clone();
394 let source = linked.source.clone();
395 let target = path.display().to_string();
396 let mut args = vec!["worktree", "remove"];
397 if force {
398 args.extend(["--force", "--force"]);
399 }
400 args.push(&target);
401 git_in_worktree(&linked, &args, &never)
404 .await
405 .map_err(|error| {
406 if force {
407 format!("git worktree remove failed: {error}")
408 } else if error.to_ascii_lowercase().contains("submodule") {
409 format!(
410 "git worktree remove failed: {error}. A worktree that contains submodules is \
411 removed only with force"
412 )
413 } else {
414 format!(
415 "git worktree remove failed: {error}. A worktree with uncommitted changes is \
416 removed only with force"
417 )
418 }
419 })?;
420 if let (Some(group), Some(name)) = (path.parent(), path.file_name()) {
421 let mut record = name.to_os_string();
422 record.push(".json");
423 let _ = std::fs::remove_file(group.join(record));
424 let _ = std::fs::remove_dir(group);
426 }
427 Ok(if branch.is_empty() {
428 format!("Removed worktree {target}.")
429 } else {
430 format!(
431 "Removed worktree {target}. Its branch {branch} is kept; delete it with `git branch \
432 -D {branch}` in {} when you no longer need it.",
433 source.display()
434 )
435 })
436}
437
438async fn git(
440 dir: &Path,
441 args: &[&str],
442 cancellation: &CancellationToken,
443) -> Result<String, String> {
444 let mut command = git_command();
445 command.arg("-C").arg(dir).args(args);
446 run(command, cancellation).await
447}
448
449async fn git_in_worktree(
453 linked: &Linked,
454 args: &[&str],
455 cancellation: &CancellationToken,
456) -> Result<String, String> {
457 let mut git_dir = std::ffi::OsString::from("--git-dir=");
458 git_dir.push(&linked.git_dir);
459 let mut work_tree = std::ffi::OsString::from("--work-tree=");
460 work_tree.push(&linked.top);
461 let mut command = git_command();
462 command.env("GIT_COMMON_DIR", &linked.common);
466 command
467 .arg("-C")
468 .arg(&linked.top)
469 .arg(git_dir)
470 .arg(work_tree)
471 .args(args);
472 run(command, cancellation).await
473}
474
475fn git_command() -> Command {
477 let mut command = Command::new("git");
478 command.args([
481 "-c",
482 "core.hooksPath=/dev/null",
483 "-c",
484 "core.fsmonitor=false",
485 ]);
486 for (name, _) in std::env::vars_os() {
490 if name
491 .to_str()
492 .is_some_and(|name| name.ends_with("_API_KEY") || name.starts_with("GIT_"))
493 {
494 command.env_remove(name);
495 }
496 }
497 command
498 .env("GIT_TERMINAL_PROMPT", "0")
499 .stdin(Stdio::null())
500 .stdout(Stdio::piped())
501 .stderr(Stdio::piped())
502 .kill_on_drop(true);
503 command
504}
505
506async fn run(mut command: Command, cancellation: &CancellationToken) -> Result<String, String> {
508 let child = command
509 .spawn()
510 .map_err(|error| format!("cannot run git: {error}"))?;
511 let output = tokio::select! {
512 output = tokio::time::timeout(GIT_TIMEOUT, child.wait_with_output()) => output
513 .map_err(|_| format!("git took longer than {} seconds", GIT_TIMEOUT.as_secs()))?
514 .map_err(|error| format!("git failed: {error}"))?,
515 () = cancellation.cancelled() => return Err("cancelled".into()),
516 };
517 let text = |bytes: &[u8]| {
518 String::from_utf8_lossy(&bytes[..bytes.len().min(MAX_GIT_OUTPUT)]).into_owned()
519 };
520 if output.status.success() {
521 Ok(text(&output.stdout))
522 } else {
523 let stderr = text(&output.stderr);
524 Err(stderr
525 .lines()
526 .find(|line| !line.trim().is_empty())
527 .unwrap_or("git exited with an error")
528 .trim()
529 .to_owned())
530 }
531}
532
533fn group_name(repo: &Path) -> String {
535 let digest = Sha256::digest(repo.as_os_str().as_encoded_bytes());
536 let hex: String = digest[..4]
537 .iter()
538 .map(|byte| format!("{byte:02x}"))
539 .collect();
540 let name = repo
541 .file_name()
542 .map(|name| slug(&name.to_string_lossy()))
543 .filter(|name| !name.is_empty())
544 .unwrap_or_else(|| "repo".into());
545 format!("{name}-{hex}")
546}
547
548fn slug(text: &str) -> String {
550 text.chars()
551 .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
552 .take(40)
553 .collect()
554}
555
556fn reserve(group: &Path, name: &str, record: &Record) -> std::io::Result<()> {
560 use std::io::Write as _;
561 use std::os::unix::fs::OpenOptionsExt as _;
562 if std::fs::symlink_metadata(group.join(name)).is_ok() {
563 return Err(std::io::ErrorKind::AlreadyExists.into());
564 }
565 let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?;
566 let path = group.join(format!("{name}.json"));
567 let mut file = std::fs::OpenOptions::new()
568 .write(true)
569 .create_new(true)
570 .mode(0o600)
571 .open(&path)?;
572 let written = file.write_all(&bytes).and_then(|()| file.sync_all());
573 if written.is_err() {
574 let _ = std::fs::remove_file(&path);
575 }
576 written
577}
578
579fn read_record(path: &Path) -> Option<Record> {
580 use std::io::Read as _;
581 use std::os::unix::fs::OpenOptionsExt as _;
582 let file = std::fs::OpenOptions::new()
583 .read(true)
584 .custom_flags(libc::O_NOFOLLOW)
585 .open(path)
586 .ok()?;
587 let mut bytes = Vec::new();
588 file.take(MAX_RECORD_BYTES).read_to_end(&mut bytes).ok()?;
589 serde_json::from_slice(&bytes).ok()
590}
591
592fn private_dir(dir: &Path) -> std::io::Result<()> {
593 use std::os::unix::fs::PermissionsExt as _;
594 std::fs::create_dir_all(dir)?;
595 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
596}
597
598#[cfg(test)]
599mod tests;