feat(worktree): add managed workspace cloning (#30117)

This commit is contained in:
Dax
2026-05-31 11:57:44 -04:00
committed by GitHub
parent 331bed2469
commit 5661af2034
23 changed files with 2374 additions and 28 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "worktree-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "worktree"
path = "src/main.rs"
[dependencies]
clap.workspace = true
worktree = { path = "../core" }
+79
View File
@@ -0,0 +1,79 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use worktree::{Create, Link, Manager};
#[derive(Parser)]
#[command(name = "worktree")]
struct Cli {
#[arg(long, hide = true)]
database: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Create {
from: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
into: Option<PathBuf>,
},
Remove {
at: PathBuf,
},
Link {
at: PathBuf,
#[arg(long)]
to: Option<PathBuf>,
},
Children {
of: PathBuf,
},
Ancestors {
of: PathBuf,
},
}
fn main() {
if let Err(error) = run() {
eprintln!("worktree: {error}");
std::process::exit(1);
}
}
fn run() -> worktree::Result<()> {
let cli = Cli::parse();
let mut manager = match cli.database {
Some(path) => Manager::open(path)?,
None => Manager::open_default()?,
};
match cli.command {
Command::Create { from, name, into } => {
println!(
"{}",
manager
.create(Create {
from: from.unwrap_or(std::env::current_dir()?),
name,
into,
})?
.display()
);
}
Command::Remove { at } => manager.remove(at)?,
Command::Link { at, to } => manager.link(Link { at, to })?,
Command::Children { of } => {
for path in manager.children(of)? {
println!("{}", path.display());
}
}
Command::Ancestors { of } => {
for path in manager.ancestors(of)? {
println!("{}", path.display());
}
}
}
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "worktree"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
dirs.workspace = true
filetime.workspace = true
libc.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
ulid.workspace = true
walkdir.workspace = true
[dev-dependencies]
tempfile.workspace = true
+195
View File
@@ -0,0 +1,195 @@
use crate::{Error, Result};
#[cfg(target_os = "linux")]
use filetime::{FileTime, set_file_times};
use std::fs;
#[cfg(target_os = "linux")]
use std::fs::{File, OpenOptions};
use std::path::Path;
#[cfg(any(target_os = "linux", test))]
use walkdir::WalkDir;
pub(crate) trait CopyStrategy {
fn copy_directory(&self, from: &Path, to: &Path) -> Result<()>;
}
pub(crate) struct CowStrategy;
impl CopyStrategy for CowStrategy {
fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> {
#[cfg(target_os = "linux")]
return copy_directory_linux(from, to);
#[cfg(target_os = "macos")]
return copy_directory_macos(from, to);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (from, to);
Err(Error::CowUnavailable(
"no copy-on-write strategy has been implemented for this platform".into(),
))
}
}
}
#[cfg(target_os = "macos")]
fn copy_directory_macos(from: &Path, to: &Path) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let source = CString::new(from.as_os_str().as_bytes())
.map_err(|_| Error::Path(format!("path contains a null byte: {}", from.display())))?;
let destination = CString::new(to.as_os_str().as_bytes())
.map_err(|_| Error::Path(format!("path contains a null byte: {}", to.display())))?;
let result = unsafe { libc::clonefile(source.as_ptr(), destination.as_ptr(), 0) };
if result == 0 {
return Ok(());
}
Err(Error::CowUnavailable(format!(
"failed to clone {}: {}",
from.display(),
std::io::Error::last_os_error()
)))
}
#[cfg(target_os = "linux")]
fn copy_directory_linux(from: &Path, to: &Path) -> Result<()> {
fs::create_dir(to)?;
fs::set_permissions(to, fs::metadata(from)?.permissions())?;
let entries = WalkDir::new(from)
.min_depth(1)
.follow_links(false)
.into_iter()
.collect::<std::result::Result<Vec<_>, _>>()?;
for entry in &entries {
let relative = entry
.path()
.strip_prefix(from)
.map_err(|error| Error::Path(error.to_string()))?;
let destination = to.join(relative);
let metadata = fs::symlink_metadata(entry.path())?;
if metadata.is_dir() {
fs::create_dir(&destination)?;
fs::set_permissions(&destination, metadata.permissions())?;
continue;
}
if metadata.is_symlink() {
copy_symlink(entry.path(), &destination)?;
continue;
}
if !metadata.is_file() {
return Err(Error::UnsupportedEntry(entry.path().to_path_buf()));
}
reflink_file(entry.path(), &destination)?;
fs::set_permissions(&destination, metadata.permissions())?;
set_file_times(
&destination,
FileTime::from_last_access_time(&metadata),
FileTime::from_last_modification_time(&metadata),
)?;
}
for entry in entries
.iter()
.rev()
.filter(|entry| entry.file_type().is_dir())
{
let destination = to.join(
entry
.path()
.strip_prefix(from)
.map_err(|error| Error::Path(error.to_string()))?,
);
let metadata = fs::metadata(entry.path())?;
set_file_times(
&destination,
FileTime::from_last_access_time(&metadata),
FileTime::from_last_modification_time(&metadata),
)?;
}
let metadata = fs::metadata(from)?;
set_file_times(
to,
FileTime::from_last_access_time(&metadata),
FileTime::from_last_modification_time(&metadata),
)?;
Ok(())
}
#[cfg(target_os = "linux")]
fn reflink_file(from: &Path, to: &Path) -> Result<()> {
use std::os::fd::AsRawFd;
const FICLONE: libc::c_ulong = 0x4004_9409;
let source = File::open(from)?;
let destination = OpenOptions::new().write(true).create_new(true).open(to)?;
let result = unsafe { libc::ioctl(destination.as_raw_fd(), FICLONE, source.as_raw_fd()) };
if result == 0 {
return Ok(());
}
let error = std::io::Error::last_os_error();
Err(Error::CowUnavailable(format!(
"failed to reflink {}: {}",
from.display(),
error
)))
}
#[cfg(unix)]
fn copy_symlink(from: &Path, to: &Path) -> Result<()> {
std::os::unix::fs::symlink(fs::read_link(from)?, to)?;
Ok(())
}
#[cfg(windows)]
fn copy_symlink(from: &Path, to: &Path) -> Result<()> {
let target = fs::read_link(from)?;
if fs::metadata(from)?.is_dir() {
std::os::windows::fs::symlink_dir(target, to)?;
return Ok(());
}
std::os::windows::fs::symlink_file(target, to)?;
Ok(())
}
#[cfg(test)]
pub(crate) struct TestStrategy;
#[cfg(test)]
impl CopyStrategy for TestStrategy {
fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> {
fs::create_dir(to)?;
for entry in WalkDir::new(from).min_depth(1).follow_links(false) {
let entry = entry?;
let destination = to.join(
entry
.path()
.strip_prefix(from)
.map_err(|error| Error::Path(error.to_string()))?,
);
if entry.file_type().is_dir() {
fs::create_dir(&destination)?;
continue;
}
if entry.file_type().is_symlink() {
copy_symlink(entry.path(), &destination)?;
continue;
}
fs::copy(entry.path(), destination)?;
}
Ok(())
}
}
#[cfg(test)]
pub(crate) struct FailureStrategy;
#[cfg(test)]
impl CopyStrategy for FailureStrategy {
fn copy_directory(&self, _from: &Path, _to: &Path) -> Result<()> {
Err(Error::CowUnavailable("test failure".into()))
}
}
+75
View File
@@ -0,0 +1,75 @@
use crate::{Error, Result};
use std::fs;
use std::path::Path;
use std::process::Command;
pub(crate) fn check_source(path: &Path) -> Result<bool> {
let git = path.join(".git");
if !git.exists() {
return Ok(false);
}
if !git.is_dir() {
return Err(Error::UnsafeGit(
"linked Git worktree sources are not supported".into(),
));
}
for state in [
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
"BISECT_LOG",
"rebase-merge",
"rebase-apply",
"index.lock",
"HEAD.lock",
] {
if git.join(state).exists() {
return Err(Error::UnsafeGit(format!("Git state in progress: {state}")));
}
}
Ok(true)
}
pub(crate) fn hide_marker(path: &Path) -> Result<()> {
let info = path.join(".git").join("info");
fs::create_dir_all(&info)?;
let exclude = info.join("exclude");
let existing = if exclude.exists() {
fs::read_to_string(&exclude)?
} else {
String::new()
};
if existing.lines().any(|line| line.trim() == "/.worktree") {
return Ok(());
}
let separator = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
fs::write(exclude, format!("{existing}{separator}/.worktree\n"))?;
Ok(())
}
pub(crate) fn detach_destination(path: &Path) -> Result<()> {
let head = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "--verify", "HEAD^{commit}"])
.output()?;
if !head.status.success() {
return Ok(());
}
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(["switch", "--detach", "--quiet", "HEAD"])
.output()?;
if output.status.success() {
return Ok(());
}
Err(Error::UnsafeGit(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
))
}
+712
View File
@@ -0,0 +1,712 @@
mod copy;
mod git;
use copy::{CopyStrategy, CowStrategy};
use rusqlite::{Connection, OptionalExtension, params};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use ulid::Ulid;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Database(#[from] rusqlite::Error),
#[error("{0}")]
Walk(#[from] walkdir::Error),
#[error("invalid path: {0}")]
Path(String),
#[error("copy-on-write cloning unavailable: {0}")]
CowUnavailable(String),
#[error("unsupported filesystem entry: {0}")]
UnsupportedEntry(PathBuf),
#[error("unsafe Git source: {0}")]
UnsafeGit(String),
#[error("worktree is not managed: {0}")]
NotManaged(PathBuf),
#[error("worktree marker does not match the registry at: {0}")]
MarkerMismatch(PathBuf),
#[error("worktree marker belongs to an unknown registry entry at: {0}")]
UnknownMarker(PathBuf),
#[error("worktree already exists: {0}")]
AlreadyExists(PathBuf),
#[error("cannot remove the original registered workspace: {0}")]
CannotRemoveRoot(PathBuf),
#[error("cannot reparent the original registered workspace: {0}")]
CannotLinkRoot(PathBuf),
#[error("cannot remove subtree while a recorded worktree path is missing: {0}")]
MissingWorktree(PathBuf),
#[error("cannot link a worktree to itself or its descendant")]
Cycle,
#[error("cannot copy a workspace into itself: {0}")]
InsideSource(PathBuf),
}
pub struct Create {
pub from: PathBuf,
pub name: Option<String>,
pub into: Option<PathBuf>,
}
pub struct Link {
pub at: PathBuf,
pub to: Option<PathBuf>,
}
#[derive(Clone)]
struct Record {
id: String,
parent_id: Option<String>,
path: PathBuf,
}
pub struct Manager {
database: Connection,
copier: Box<dyn CopyStrategy>,
}
impl Manager {
pub fn open_default() -> Result<Self> {
let path = default_database_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::open(path)
}
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::with_copier(path, Box::new(CowStrategy))
}
fn with_copier(path: impl AsRef<Path>, copier: Box<dyn CopyStrategy>) -> Result<Self> {
let database = Connection::open(path)?;
database.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS worktree (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES worktree(id) ON DELETE CASCADE,
path TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS worktree_parent_id_idx ON worktree(parent_id);",
)?;
Ok(Self { database, copier })
}
pub fn create(&mut self, input: Create) -> Result<PathBuf> {
let from = existing_directory(&input.from)?;
let git = git::check_source(&from)?;
let (source, register_source) = self.source(&from)?;
let root = self.root(&source)?;
let id = Ulid::new().to_string();
let destination_parent = match input.into {
Some(path) => absolute_path(&path)?,
None => default_storage(&root.path)?,
};
let name = destination_name(input.name, &id)?;
if destination_parent.join(&name).starts_with(&from) {
return Err(Error::InsideSource(destination_parent.join(name)));
}
fs::create_dir_all(&destination_parent)?;
let destination_parent = fs::canonicalize(destination_parent)?;
let destination = destination_parent.join(name);
if destination.starts_with(&from) {
return Err(Error::InsideSource(destination));
}
if destination.exists() {
return Err(Error::AlreadyExists(destination));
}
if let Err(error) = self.copier.copy_directory(&from, &destination) {
let _ = fs::remove_dir_all(&destination);
return Err(error);
}
let result = (|| {
write_marker(&destination, &id)?;
if git {
git::hide_marker(&destination)?;
git::detach_destination(&destination)?;
}
if register_source {
write_marker(&from, &source.id)?;
self.database.execute(
"INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, NULL, ?2, ?3)",
params![source.id, path_text(&from)?, timestamp()],
)?;
}
if git {
git::hide_marker(&from)?;
}
self.database.execute(
"INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, ?2, ?3, ?4)",
params![id, source.id, path_text(&destination)?, timestamp()],
)?;
Ok(destination.clone())
})();
if result.is_err() {
let _ = fs::remove_dir_all(&destination);
}
result
}
pub fn remove(&mut self, at: impl AsRef<Path>) -> Result<()> {
let at = existing_directory(at.as_ref())?;
let record = self.record_at(&at)?;
if record.parent_id.is_none() {
return Err(Error::CannotRemoveRoot(at));
}
verify_marker(&record)?;
let mut statement = self.database.prepare(
"WITH RECURSIVE subtree(id, path, depth) AS (
SELECT id, path, 0 FROM worktree WHERE id = ?1
UNION ALL
SELECT worktree.id, worktree.path, subtree.depth + 1
FROM worktree JOIN subtree ON worktree.parent_id = subtree.id
) SELECT id, path, depth FROM subtree ORDER BY depth DESC",
)?;
let rows = statement
.query_map([&record.id], |row| {
Ok((
row.get::<_, String>(0)?,
PathBuf::from(row.get::<_, String>(1)?),
row.get::<_, i64>(2)?,
))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
drop(statement);
for (id, path, _) in &rows {
if !path.exists() {
return Err(Error::MissingWorktree(path.clone()));
}
verify_marker(&Record {
id: id.clone(),
parent_id: None,
path: path.clone(),
})?;
}
for (id, path, _) in &rows {
fs::remove_dir_all(path)?;
self.database
.execute("DELETE FROM worktree WHERE id = ?1", [id])?;
}
Ok(())
}
pub fn link(&mut self, input: Link) -> Result<()> {
let at = existing_directory(&input.at)?;
let record = match read_marker(&at)? {
Some(id) => {
let record = self
.record_id(&id)?
.ok_or_else(|| Error::UnknownMarker(at.clone()))?;
if record.path != at {
if record.path.exists() {
return Err(Error::MarkerMismatch(at));
}
self.database.execute(
"UPDATE worktree SET path = ?1 WHERE id = ?2",
params![path_text(&at)?, record.id],
)?;
}
Record {
path: at.clone(),
..record
}
}
None => {
let record = self.record_at(&at)?;
write_marker(&at, &record.id)?;
record
}
};
if at.join(".git").is_dir() {
git::hide_marker(&at)?;
}
let Some(to) = input.to else {
return Ok(());
};
if record.parent_id.is_none() {
return Err(Error::CannotLinkRoot(at));
}
let parent = self.record_at(&existing_directory(&to)?)?;
if parent.id == record.id || self.is_descendant(&parent.id, &record.id)? {
return Err(Error::Cycle);
}
self.database.execute(
"UPDATE worktree SET parent_id = ?1 WHERE id = ?2",
params![parent.id, record.id],
)?;
Ok(())
}
pub fn children(&self, of: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let record = self.record_at(&existing_directory(of.as_ref())?)?;
let mut statement = self
.database
.prepare("SELECT path FROM worktree WHERE parent_id = ?1 ORDER BY created_at, id")?;
Ok(statement
.query_map([record.id], |row| {
Ok(PathBuf::from(row.get::<_, String>(0)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn ancestors(&self, of: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let record = self.record_at(&existing_directory(of.as_ref())?)?;
let mut paths = Vec::new();
let mut parent_id = record.parent_id;
while let Some(id) = parent_id {
let parent = self
.record_id(&id)?
.ok_or_else(|| Error::NotManaged(record.path.clone()))?;
paths.push(parent.path);
parent_id = parent.parent_id;
}
Ok(paths)
}
fn source(&self, path: &Path) -> Result<(Record, bool)> {
if let Some(id) = read_marker(path)? {
let record = self
.record_id(&id)?
.ok_or_else(|| Error::UnknownMarker(path.to_path_buf()))?;
if record.path != path {
return Err(Error::MarkerMismatch(path.to_path_buf()));
}
return Ok((record, false));
}
if self.record_at_optional(path)?.is_some() {
return Err(Error::MarkerMismatch(path.to_path_buf()));
}
let id = Ulid::new().to_string();
Ok((
Record {
id,
parent_id: None,
path: path.to_path_buf(),
},
true,
))
}
fn root(&self, record: &Record) -> Result<Record> {
let mut current = record.clone();
while let Some(id) = current.parent_id.clone() {
current = self
.record_id(&id)?
.ok_or_else(|| Error::NotManaged(record.path.clone()))?;
}
Ok(current)
}
fn record_at(&self, path: &Path) -> Result<Record> {
self.record_at_optional(path)?
.ok_or_else(|| Error::NotManaged(path.to_path_buf()))
}
fn record_at_optional(&self, path: &Path) -> Result<Option<Record>> {
self.database
.query_row(
"SELECT id, parent_id, path FROM worktree WHERE path = ?1",
[path_text(path)?],
|row| {
Ok(Record {
id: row.get(0)?,
parent_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
})
},
)
.optional()
.map_err(Error::from)
}
fn record_id(&self, id: &str) -> Result<Option<Record>> {
self.database
.query_row(
"SELECT id, parent_id, path FROM worktree WHERE id = ?1",
[id],
|row| {
Ok(Record {
id: row.get(0)?,
parent_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
})
},
)
.optional()
.map_err(Error::from)
}
fn is_descendant(&self, candidate: &str, of: &str) -> Result<bool> {
Ok(self.database.query_row(
"WITH RECURSIVE descendants(id) AS (
SELECT id FROM worktree WHERE parent_id = ?1
UNION ALL
SELECT worktree.id FROM worktree JOIN descendants ON worktree.parent_id = descendants.id
) SELECT EXISTS(SELECT 1 FROM descendants WHERE id = ?2)",
params![of, candidate],
|row| row.get(0),
)?)
}
}
fn default_database_path() -> Result<PathBuf> {
let base = dirs::data_local_dir()
.ok_or_else(|| Error::Path("user data directory is unavailable".into()))?;
Ok(base.join("worktree").join("worktree.sqlite"))
}
fn existing_directory(path: &Path) -> Result<PathBuf> {
let path = fs::canonicalize(path)?;
if !path.is_dir() {
return Err(Error::Path(format!("not a directory: {}", path.display())));
}
Ok(path)
}
fn absolute_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
Ok(std::env::current_dir()?.join(path))
}
fn default_storage(root: &Path) -> Result<PathBuf> {
let parent = root
.parent()
.ok_or_else(|| Error::Path(format!("workspace has no parent: {}", root.display())))?;
let name = root
.file_name()
.ok_or_else(|| Error::Path(format!("workspace has no name: {}", root.display())))?;
Ok(parent.join(".worktrees").join(name))
}
fn destination_name(name: Option<String>, id: &str) -> Result<String> {
let name = name.unwrap_or_else(|| id.to_owned());
if name.is_empty() || name == "." || name == ".." || Path::new(&name).components().count() != 1
{
return Err(Error::Path(format!("invalid worktree name: {name}")));
}
Ok(name)
}
fn marker(path: &Path) -> PathBuf {
path.join(".worktree")
}
fn write_marker(path: &Path, id: &str) -> Result<()> {
fs::write(marker(path), format!("{id}\n"))?;
Ok(())
}
fn read_marker(path: &Path) -> Result<Option<String>> {
let marker = marker(path);
if !marker.exists() {
return Ok(None);
}
Ok(Some(fs::read_to_string(marker)?.trim().to_owned()))
}
fn verify_marker(record: &Record) -> Result<()> {
if read_marker(&record.path)?.as_deref() == Some(&record.id) {
return Ok(());
}
Err(Error::MarkerMismatch(record.path.clone()))
}
fn path_text(path: &Path) -> Result<String> {
path.to_str()
.map(ToOwned::to_owned)
.ok_or_else(|| Error::Path(format!("path is not valid UTF-8: {}", path.display())))
}
fn timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::copy::{FailureStrategy, TestStrategy};
use std::process::Command;
use tempfile::TempDir;
fn manager(temp: &TempDir) -> Manager {
Manager::with_copier(temp.path().join("registry.sqlite"), Box::new(TestStrategy)).unwrap()
}
fn source(temp: &TempDir) -> PathBuf {
let source = temp.path().join("app");
fs::create_dir(&source).unwrap();
fs::write(source.join("file.txt"), "hello").unwrap();
source
}
#[test]
fn create_tracks_parentage_and_default_storage() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source.clone(),
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: first.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
assert_eq!(first, temp.path().join(".worktrees/app/first"));
assert_eq!(second, temp.path().join(".worktrees/app/second"));
assert_ne!(
fs::read_to_string(source.join(".worktree")).unwrap(),
fs::read_to_string(first.join(".worktree")).unwrap()
);
assert_eq!(manager.children(&source).unwrap(), vec![first.clone()]);
assert_eq!(manager.ancestors(&second).unwrap(), vec![first, source]);
}
#[test]
fn remove_deletes_a_full_subtree() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source.clone(),
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: first.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
manager.remove(&first).unwrap();
assert!(!first.exists());
assert!(!second.exists());
assert!(manager.children(&source).unwrap().is_empty());
assert!(matches!(
manager.remove(&source),
Err(Error::CannotRemoveRoot(_))
));
}
#[test]
fn remove_refuses_a_subtree_with_an_unlinked_move() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source,
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: first.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
fs::rename(&second, temp.path().join("moved")).unwrap();
assert!(matches!(
manager.remove(&first),
Err(Error::MissingWorktree(_))
));
assert!(first.exists());
}
#[test]
fn link_restores_moves_markers_and_reparents() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source.clone(),
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: source.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
let moved = temp.path().join("moved");
fs::rename(&second, &moved).unwrap();
manager
.link(Link {
at: moved.clone(),
to: Some(first.clone()),
})
.unwrap();
assert_eq!(
manager.ancestors(&moved).unwrap(),
vec![first, source.clone()]
);
fs::remove_file(source.join(".worktree")).unwrap();
manager
.link(Link {
at: source.clone(),
to: None,
})
.unwrap();
assert!(source.join(".worktree").exists());
}
#[test]
fn link_does_not_reparent_a_registered_source() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let child = manager
.create(Create {
from: source.clone(),
name: Some("child".into()),
into: None,
})
.unwrap();
assert!(matches!(
manager.link(Link {
at: source.clone(),
to: Some(child),
}),
Err(Error::CannotLinkRoot(_))
));
assert!(matches!(
manager.remove(&source),
Err(Error::CannotRemoveRoot(_))
));
}
#[test]
fn git_copy_detaches_head_and_preserves_dirty_state() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
run(&source, &["init"]);
run(&source, &["config", "user.email", "test@example.com"]);
run(&source, &["config", "user.name", "Test"]);
run(&source, &["add", "file.txt"]);
run(&source, &["commit", "-m", "initial"]);
fs::write(source.join("file.txt"), "changed").unwrap();
run(&source, &["add", "file.txt"]);
fs::write(source.join("untracked.txt"), "new").unwrap();
let mut manager = manager(&temp);
let destination = manager
.create(Create {
from: source.clone(),
name: Some("git".into()),
into: None,
})
.unwrap();
assert!(
!Command::new("git")
.arg("-C")
.arg(&destination)
.args(["symbolic-ref", "-q", "HEAD"])
.status()
.unwrap()
.success()
);
let staged = Command::new("git")
.arg("-C")
.arg(&destination)
.args(["diff", "--cached", "--name-only"])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&staged.stdout).contains("file.txt"));
assert!(destination.join("untracked.txt").exists());
let status = Command::new("git")
.arg("-C")
.arg(&destination)
.args(["status", "--porcelain", "--", ".worktree"])
.output()
.unwrap();
assert!(status.stdout.is_empty());
}
#[test]
fn unsafe_git_source_is_rejected_without_registering_it() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
run(&source, &["init"]);
fs::write(source.join(".git/MERGE_HEAD"), "commit").unwrap();
let mut manager = manager(&temp);
assert!(matches!(
manager.create(Create {
from: source.clone(),
name: Some("unsafe".into()),
into: None,
}),
Err(Error::UnsafeGit(_))
));
assert!(!source.join(".worktree").exists());
}
#[test]
fn unavailable_cow_does_not_register_the_source() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = Manager::with_copier(
temp.path().join("registry.sqlite"),
Box::new(FailureStrategy),
)
.unwrap();
assert!(matches!(
manager.create(Create {
from: source.clone(),
name: Some("failure".into()),
into: None,
}),
Err(Error::CowUnavailable(_))
));
assert!(!source.join(".worktree").exists());
assert!(manager.record_at_optional(&source).unwrap().is_none());
}
fn run(path: &Path, args: &[&str]) {
assert!(
Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap()
.success()
);
}
}