|
|
@@ -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()
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|