feat(worktree): add managed workspace cloning (#30117)
This commit is contained in:
@@ -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" }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user