Skip to main content

scv_tools/delegate/
worktree.rs

1//! Git worktrees SCV creates so a job's agent works apart from its
2//! repository's own checkout (`agent` calls with `worktree: true`).
3//!
4//! A worktree lives under the instance's
5//! [`Layout::worktrees`](scv_client::Layout::worktrees), grouped by
6//! repository as `<repository>-<digest>/<agent>-<6 hex>`, on a new branch
7//! `scv/<agent>-<6 hex>` from the repository's current `HEAD`, with a private
8//! record beside it (`<agent>-<6 hex>.json`) naming the checkout it was made
9//! from. SCV runs `git` only to create, list, and remove these worktrees,
10//! with fixed arguments, the repository's hooks and file-system monitor
11//! off, no `*_API_KEY` variables, and a time limit; it never commits, merges, or deletes a
12//! branch. It runs `git` in a worktree only once its link checks out: its
13//! git directory must be the one the repository it was made from keeps for
14//! it, so a worktree's agent cannot point git at configuration it wrote. A
15//! worktree counts as part of the workspace its checkout is in, so an agent
16//! call may name it as `cwd`.
17
18use 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
31/// How long one `git` command may take.
32const GIT_TIMEOUT: Duration = Duration::from_secs(30);
33/// Longest output read from one `git` command.
34const MAX_GIT_OUTPUT: usize = 64 * 1024;
35/// Largest worktree record read.
36const MAX_RECORD_BYTES: u64 = 16 * 1024;
37/// Names drawn for a new worktree before giving up on a free one.
38const NAME_DRAWS: usize = 8;
39
40/// A worktree SCV just created, and where the agent runs in it.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Created {
43    pub worktree: WorktreeRef,
44    /// The directory the call asked for, inside the worktree.
45    pub cwd: PathBuf,
46}
47
48/// What SCV records beside each worktree it created.
49#[derive(Debug, Serialize, Deserialize)]
50struct Record {
51    v: u32,
52    /// The checkout the worktree was made from: the top of the repository
53    /// (or linked worktree) holding the call's `cwd`.
54    source: PathBuf,
55    branch: String,
56    /// The common git directory of `source`'s repository (canonical),
57    /// whose `worktrees/` holds the worktree's git directory.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    common: Option<PathBuf>,
60}
61
62/// An SCV worktree whose git link checked out.
63struct Linked {
64    top: PathBuf,
65    /// Its git directory, which the repository it was made from keeps.
66    git_dir: PathBuf,
67    /// The common git directory that check accepted. Git is told it
68    /// outright: the worktree's agent can rewrite `commondir` after the
69    /// check, and a later read would use whatever repository that names.
70    common: PathBuf,
71    /// The checkout it was made from.
72    source: PathBuf,
73}
74
75/// Create a worktree under `root` for an `agent` call that would run in
76/// `cwd`, a directory of a git repository whose top is inside `workspace`
77/// (canonical), and return where it is. A worktree whose creation fails is
78/// removed again; its branch may stay.
79pub(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    // A name already taken (by a worktree or its record) is drawn again, so
114    // this call never touches a worktree it did not create.
115    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    // A directory the repository does not track has no copy in the worktree.
155    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
170/// Undo a worktree whose creation failed or was cut off: git may have left
171/// it half made and locked.
172async 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
187/// The top of the SCV worktree under `root` holding `path`, when `path` is
188/// in one.
189pub(crate) fn worktree_path(root: &Path, path: &Path) -> Option<PathBuf> {
190    worktree_top(root, path).map(|linked| linked.top)
191}
192
193/// The checkout the SCV worktree under `root` holding `path` was made from,
194/// when `path` is in one.
195pub fn worktree_repo(root: &Path, path: &Path) -> Option<PathBuf> {
196    worktree_top(root, path).map(|linked| linked.source)
197}
198
199/// The SCV worktree under `root` that holds `path`, when its git link
200/// checks out. The branch is the one SCV recorded beside the worktree.
201pub(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
211/// The SCV worktree under `root` that holds `path`, when its link checks
212/// out ([`verified_git_dir`]).
213fn 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
224/// The top of the directory under `root` that holds `path`, where SCV puts
225/// a worktree, and SCV's record beside it; nothing about its git link is
226/// checked yet.
227fn 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
244/// The git directory of the SCV worktree at `top` (canonical) that
245/// `record` describes, when its link checks out: it must be exactly
246/// `<common>/worktrees/<name>` of the repository the worktree was made
247/// from, and it and the worktree's `.git` file must name each other. An
248/// agent can rewrite its worktree's `.git`, but not make git read another
249/// git directory's configuration (filters, for one) through it.
250fn 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    // Git may write each link relative to the file holding it.
258    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
266/// The common git directory of the repository `checkout` (the top of a
267/// checkout) belongs to: its `.git` directory or, for a linked worktree,
268/// the one its git directory's `commondir` names.
269fn 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
282/// A short text file's contents.
283fn 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
291/// Whether `path` is inside an SCV worktree under `root` made from a
292/// checkout inside `workspace` (canonical).
293pub(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
297/// Every worktree SCV created under `root`, with whether it has uncommitted
298/// changes.
299pub async fn list(root: &Path) -> Vec<WorktreeInfo> {
300    let mut found = Vec::new();
301    // Canonical, as the paths jobs record are.
302    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            // One whose link does not check out is listed from SCV's record
323            // alone, with no git run in it: its state is unknown.
324            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                    // `--ignore-submodules=all`: a gitlink's directory is empty
338                    // until its agent fills it. `git status` would otherwise
339                    // enter that repository and run a clean filter the agent
340                    // wrote there.
341                    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
368/// Remove the SCV worktree at `path` under `root` (`git worktree remove`),
369/// refusing one with uncommitted changes unless `force`, which also removes
370/// one a cut-off creation left locked. Its branch stays.
371pub 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 removes a worktree from inside it too, which works whether or not
402    // the checkout it came from is still there.
403    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        // The repository's group goes with its last worktree.
425        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
438/// Run `git -C <dir> <args>` and return its standard output.
439async 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
449/// Run `git <args>` in the verified worktree `linked`, naming its git
450/// directory and top outright rather than finding them again through its
451/// `.git` file, which its agent may change after the check.
452async 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    // `commondir` is read again by git. Pin the directory the check accepted
463    // so a rewrite in between cannot point git at another repository's
464    // filters.
465    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
475/// `git`, with what SCV never lets it use.
476fn git_command() -> Command {
477    let mut command = Command::new("git");
478    // Hooks and a file-system monitor are commands the repository's (or an
479    // agent's) configuration names, which no one asked SCV to run.
480    command.args([
481        "-c",
482        "core.hooksPath=/dev/null",
483        "-c",
484        "core.fsmonitor=false",
485    ]);
486    // Git and what it runs (filters) need no model or provider keys.
487    // Inherited `GIT_*` would redirect `rev-parse` and `worktree add`,
488    // which do not pass `--git-dir` themselves.
489    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
506/// Run `command`, a [`git_command`], and return its standard output.
507async 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
533/// `<repository name>-<8 hex of its path>`, one directory per repository.
534fn 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
548/// Letters, digits, `-`, and `_` of `text`, at most 40 of them.
549fn slug(text: &str) -> String {
550    text.chars()
551        .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
552        .take(40)
553        .collect()
554}
555
556/// Take the name `name` in `group` for a new worktree: write its record,
557/// which must not exist yet, unless a worktree of that name does
558/// (`AlreadyExists` either way).
559fn 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;