use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const DEFAULT_BRANCH_PREFIX: &str = "card/";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Checkout {
pub path: String,
pub branch: Option<String>,
pub commit: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub project: wsb_prj::Project,
pub name: String,
}
impl Target {
pub fn parse(prj: &wsb_prj::Prj, raw: &str) -> Result<Self, String> {
let (project, name) = raw
.split_once('/')
.ok_or_else(|| format!("'{raw}' is not <project>/<worktree>"))?;
if !wsb_prj::is_valid_name(name) {
return Err(format!(
"'{name}' is not a worktree name: letters, digits, '.', '_' and '-', \
starting with a letter or digit"
));
}
if name == wsb_prj::MAIN {
return Err(format!(
"{project}'s main worktree is its clone, not a worktree of one"
));
}
Ok(Self {
project: prj.project(project).map_err(|e| e.to_string())?,
name: name.to_string(),
})
}
pub fn path(&self) -> PathBuf {
self.project.dir().join(wsb_prj::worktree_folder(&self.name))
}
pub fn main(&self) -> PathBuf {
self.project.main_worktree()
}
}
impl std::fmt::Display for Target {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.project.name(), self.name)
}
}
pub fn default_branch(name: &str) -> String {
let prefix = std::env::var("PRJ_BRANCH_PREFIX")
.ok()
.filter(|prefix| !prefix.is_empty())
.unwrap_or_else(|| DEFAULT_BRANCH_PREFIX.to_string());
format!("{prefix}{name}")
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let output = crate::git_backend::git_command()
.arg("-C")
.arg(dir)
.args(args)
.output()
.map_err(|e| format!("cannot run git in {}: {e}", dir.display()))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim_end().to_string())
} else {
Err(format!(
"git {} in {} failed: {}",
args.join(" "),
dir.display(),
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
fn git_ok(dir: &Path, args: &[&str]) -> Result<bool, String> {
let output = crate::git_backend::git_command()
.arg("-C")
.arg(dir)
.args(args)
.output()
.map_err(|e| format!("cannot run git in {}: {e}", dir.display()))?;
Ok(output.status.success())
}
fn path_arg(path: &Path) -> Result<&str, String> {
path.to_str()
.ok_or_else(|| format!("{} is not a path git can be given", path.display()))
}
fn common_dir(dir: &Path) -> Result<PathBuf, String> {
let raw = git(dir, &["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
crate::platform::canonicalize(&raw).map_err(|e| format!("cannot resolve {raw}: {e}"))
}
fn git_dir(dir: &Path) -> Result<PathBuf, String> {
let raw = git(dir, &["rev-parse", "--path-format=absolute", "--git-dir"])?;
crate::platform::canonicalize(&raw).map_err(|e| format!("cannot resolve {raw}: {e}"))
}
fn has_local_branch(repo: &Path, branch: &str) -> Result<bool, String> {
git_ok(repo, &["rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")])
}
fn has_remote_branch(repo: &Path, branch: &str) -> Result<bool, String> {
git_ok(repo, &["rev-parse", "--verify", "--quiet", &format!("refs/remotes/origin/{branch}")])
}
fn has_commit(repo: &Path, commit: &str) -> Result<bool, String> {
git_ok(repo, &["cat-file", "-e", &format!("{commit}^{{commit}}")])
}
fn branch_of(dir: &Path) -> Result<Option<String>, String> {
let branch = git(dir, &["branch", "--show-current"])?;
Ok((!branch.is_empty()).then_some(branch))
}
fn head_of(dir: &Path) -> Result<String, String> {
git(dir, &["rev-parse", "HEAD"])
}
fn gitlinks(checkout: &Path) -> Result<Vec<(String, String)>, String> {
if !checkout.join(".gitmodules").is_file() {
return Ok(Vec::new());
}
let listed = git(
checkout,
&["config", "--file", ".gitmodules", "--get-regexp", r"^submodule\..*\.path$"],
)
.or_else(|error| {
if git_ok(checkout, &["config", "--file", ".gitmodules", "--list"]).unwrap_or(false) {
Ok(String::new())
} else {
Err(error)
}
})?;
let mut links = Vec::new();
for line in listed.lines() {
let (_, path) = line
.split_once(' ')
.ok_or_else(|| format!("{}/.gitmodules: cannot read {line:?}", checkout.display()))?;
let entry = git(checkout, &["ls-tree", "HEAD", "--", path])?;
let mut fields = entry.split_whitespace();
match (fields.next(), fields.next(), fields.next()) {
(Some("160000"), Some("commit"), Some(sha)) => links.push((path.to_string(), sha.to_string())),
_ => {
return Err(format!(
"{}/.gitmodules declares {path}, and the commit there records no submodule at it",
checkout.display()
))
}
}
}
Ok(links)
}
fn registrations(repo: &Path) -> Result<Vec<Registration>, String> {
let listed = git(repo, &["worktree", "list", "--porcelain"])?;
let mut found = Vec::new();
for block in listed.split("\n\n") {
let mut registration = Registration::default();
for line in block.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
registration.path = PathBuf::from(path);
} else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
registration.branch = Some(branch.to_string());
} else if line == "locked" || line.starts_with("locked ") {
registration.locked = true;
} else if line == "prunable" || line.starts_with("prunable ") {
registration.prunable = true;
}
}
if !registration.path.as_os_str().is_empty() {
found.push(registration);
}
}
Ok(found)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct Registration {
path: PathBuf,
branch: Option<String>,
locked: bool,
prunable: bool,
}
fn same_path(one: &Path, other: &Path) -> bool {
match (crate::platform::canonicalize(one), crate::platform::canonicalize(other)) {
(Ok(one), Ok(other)) => one == other,
_ => one == other,
}
}
fn forget_stale_registration(repo: &Path, path: &Path) -> Result<(), String> {
let admin = common_dir(repo)?.join("worktrees");
let Ok(entries) = std::fs::read_dir(&admin) else {
return Ok(());
};
let wanted = path.join(".git");
for entry in entries {
let entry = entry.map_err(|e| format!("cannot read {}: {e}", admin.display()))?;
let Ok(recorded) = std::fs::read_to_string(entry.path().join("gitdir")) else {
continue;
};
if !same_path(Path::new(recorded.trim()), &wanted) && Path::new(recorded.trim()) != wanted {
continue;
}
if path.join(".git").is_file() && git_ok(path, &["rev-parse", "--git-dir"])? {
return Err(format!(
"{} is still a checkout of {}; it is not forgotten",
path.display(),
repo.display()
));
}
std::fs::remove_dir_all(entry.path())
.map_err(|e| format!("cannot remove {}: {e}", entry.path().display()))?;
}
Ok(())
}
fn holder_of(repo: &Path, branch: &str) -> Result<Option<PathBuf>, String> {
Ok(registrations(repo)?
.into_iter()
.find(|registration| registration.branch.as_deref() == Some(branch))
.map(|registration| registration.path))
}
struct Made {
repo: PathBuf,
path: PathBuf,
created_branch: Option<String>,
}
pub fn add(target: &Target, branch: Option<&str>, from: Option<&str>) -> Result<Vec<Checkout>, String> {
let path = target.path();
if path.exists() {
return Err(format!("cannot create worktree {target}: {} already exists", path.display()));
}
let main = target.main();
let branch = branch.map(str::to_string).unwrap_or_else(|| default_branch(&target.name));
let reason = format!("wsb worktree {target}");
let mut made = Vec::new();
let outcome = (|| -> Result<Vec<Checkout>, String> {
let destination = path_arg(&path)?;
let created = if has_local_branch(&main, &branch)? {
git(&main, &["worktree", "add", "--quiet", destination, &branch])?;
None
} else if has_remote_branch(&main, &branch)? {
git(
&main,
&["worktree", "add", "--quiet", "--track", "-b", &branch, destination, &format!("origin/{branch}")],
)?;
Some(branch.clone())
} else {
let base = match from {
Some(from) => from.to_string(),
None => git(&main, &["rev-parse", "--abbrev-ref", "origin/HEAD"]).map_err(|_| {
format!(
"{} has no origin/HEAD to start {branch} from; name one with --from",
main.display()
)
})?,
};
git(&main, &["worktree", "add", "--quiet", "--no-track", "-b", &branch, destination, &base])?;
Some(branch.clone())
};
made.push(Made {
repo: main.clone(),
path: path.clone(),
created_branch: created,
});
git(&main, &["worktree", "lock", "--reason", &reason, destination])?;
let mut checkouts = vec![Checkout {
path: String::new(),
branch: branch_of(&path)?,
commit: head_of(&path)?,
}];
add_submodules(&main, &path, "", &branch, &reason, &mut made, &mut checkouts)?;
Ok(checkouts)
})();
match outcome {
Ok(checkouts) => Ok(checkouts),
Err(error) => {
let undone = undo(made);
Err(match undone {
Ok(()) => format!("{error}\n nothing of {target} was left behind"),
Err(stuck) => format!("{error}\n and undoing what was made failed: {stuck}"),
})
}
}
}
fn add_submodules(
there: &Path,
here: &Path,
prefix: &str,
branch: &str,
reason: &str,
made: &mut Vec<Made>,
checkouts: &mut Vec<Checkout>,
) -> Result<(), String> {
for (path, commit) in gitlinks(here)? {
let repo = there.join(&path);
let destination = here.join(&path);
let shown = format!("{prefix}{path}");
if !repo.join(".git").exists() {
return Err(format!(
"{} is not checked out in main, so its repository cannot be shared; run \
`git submodule update --init --recursive` in {} first",
shown,
there.display()
));
}
let arg = path_arg(&destination)?;
let created = if has_local_branch(&repo, branch)? {
git(&repo, &["worktree", "add", "--quiet", arg, branch])?;
None
} else {
if !has_commit(&repo, &commit)? {
return Err(format!(
"{shown}'s repository in main has no commit {commit}, which the worktree pins; \
fetch it in {} first",
repo.display()
));
}
git(&repo, &["worktree", "add", "--quiet", "-b", branch, arg, &commit])?;
Some(branch.to_string())
};
made.push(Made {
repo: repo.clone(),
path: destination.clone(),
created_branch: created,
});
git(&repo, &["worktree", "lock", "--reason", reason, arg])?;
checkouts.push(Checkout {
path: shown.clone(),
branch: branch_of(&destination)?,
commit: head_of(&destination)?,
});
add_submodules(&repo, &destination, &format!("{shown}/"), branch, reason, made, checkouts)?;
}
Ok(())
}
fn undo(made: Vec<Made>) -> Result<(), String> {
let mut failures = Vec::new();
for made in made.into_iter().rev() {
let arg = match path_arg(&made.path) {
Ok(arg) => arg.to_string(),
Err(error) => {
failures.push(error);
continue;
}
};
let _ = git(&made.repo, &["worktree", "unlock", &arg]);
if let Err(error) = git(&made.repo, &["worktree", "remove", "--force", "--force", &arg]) {
failures.push(error);
continue;
}
if let Some(branch) = made.created_branch {
if let Err(error) = git(&made.repo, &["branch", "-D", &branch]) {
failures.push(error);
}
}
}
if failures.is_empty() {
Ok(())
} else {
Err(failures.join("; "))
}
}
#[derive(Debug, Clone)]
struct Member {
path: String,
here: PathBuf,
there: PathBuf,
depth: usize,
broken: bool,
}
fn is_broken(dir: &Path) -> Result<bool, String> {
Ok(dir.join(".git").exists() && !git_ok(dir, &["rev-parse", "--git-dir"])?)
}
fn members(root: &Path, main: &Path) -> Result<Vec<Member>, String> {
let mut found = vec![Member {
path: String::new(),
here: root.to_path_buf(),
there: main.to_path_buf(),
depth: 0,
broken: false,
}];
let mut index = 0;
while index < found.len() {
let parent = found[index].clone();
index += 1;
if parent.broken {
continue;
}
for (path, _) in gitlinks(&parent.here)? {
let here = parent.here.join(&path);
if !here.join(".git").exists() {
continue;
}
found.push(Member {
path: if parent.path.is_empty() { path.clone() } else { format!("{}/{path}", parent.path) },
broken: is_broken(&here)?,
here,
there: parent.there.join(&path),
depth: parent.depth + 1,
});
}
}
Ok(found)
}
fn unsaved(dir: &Path, taken_at: Option<&str>) -> Result<Option<String>, String> {
let status = git(dir, &["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"])?;
if !status.is_empty() {
let count = status.lines().count();
return Ok(Some(format!("{count} uncommitted change(s), untracked files included")));
}
if let Some(taken_at) = taken_at {
let made = git(dir, &["rev-list", "--count", "HEAD", "--not", taken_at, "--branches", "--tags", "--remotes"])?;
if made != "0" {
return Ok(Some(format!("{made} commit(s) made here, on no branch")));
}
return Ok(None);
}
let unpushed = git(dir, &["rev-list", "--count", "HEAD", "--not", "--remotes"])?;
if unpushed != "0" {
return Ok(Some(format!("{unpushed} commit(s) on no remote branch")));
}
Ok(None)
}
pub fn remove(target: &Target, delete_branch: bool) -> Result<Vec<Checkout>, String> {
let path = target.path();
if !path.is_dir() {
return Err(format!("{target} is not there: {} does not exist", path.display()));
}
let main = target.main();
if common_dir(&path)? != common_dir(&main)? {
return Err(format!(
"{} is not a worktree of {}; it is not removed",
path.display(),
main.display()
));
}
let members = members(&path, &main)?;
let temporary = temporary(&path)?;
let mut refused = Vec::new();
for member in &members {
if member.broken {
refused.push(format!(
" {}: git no longer knows this checkout — `wsb worktree migrate {target}` repairs it",
member.path
));
continue;
}
let shown = if member.path.is_empty() { ".".to_string() } else { member.path.clone() };
let taken_at = match &temporary {
Some(temporary) => Some(temporary.commits.get(&shown).map(String::as_str).ok_or_else(|| {
format!("{target} is a temporary worktree whose record names no commit for {shown}; it is not removed")
})?),
None => None,
};
if let Some(why) = unsaved(&member.here, taken_at)? {
refused.push(format!(" {shown}: {why}"));
}
if !member.path.is_empty() && common_dir(&member.here)? != common_dir(&member.there)? {
refused.push(format!(
" {}: a separate clone, not a worktree of main's repository — \
`wsb worktree migrate {target}` first",
member.path
));
}
}
if !refused.is_empty() {
return Err(format!(
"{target} is not removed; its work would be lost:\n{}",
refused.join("\n")
));
}
let mut removed = Vec::new();
let mut ordered = members;
ordered.sort_by(|a, b| b.depth.cmp(&a.depth));
for member in ordered {
let branch = branch_of(&member.here)?;
let commit = head_of(&member.here)?;
let arg = path_arg(&member.here)?.to_string();
let repo = if member.path.is_empty() { main.clone() } else { member.there.clone() };
let parent = !gitlinks(&member.here)?.is_empty();
if registrations(&repo)?
.iter()
.any(|registration| same_path(®istration.path, &member.here) && registration.locked)
{
git(&repo, &["worktree", "unlock", &arg])?;
}
if parent {
git(&repo, &["worktree", "remove", "--force", &arg])?;
} else {
git(&repo, &["worktree", "remove", &arg])?;
}
if delete_branch {
if let Some(branch) = &branch {
git(&repo, &["branch", "-d", branch]).map_err(|error| {
format!("{} is removed, and its branch {branch} is kept: {error}", member.here.display())
})?;
}
}
removed.push(Checkout {
path: member.path,
branch,
commit,
});
}
Ok(removed)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Temporary {
pub of: String,
pub working: bool,
pub commits: BTreeMap<String, String>,
}
const TEMPORARY: &str = "wsb-temporary.json";
pub fn temporary(root: &Path) -> Result<Option<Temporary>, String> {
if !root.is_dir() || !git_ok(root, &["rev-parse", "--is-inside-work-tree"])? {
return Ok(None);
}
let file = git_dir(root)?.join(TEMPORARY);
match std::fs::read_to_string(&file) {
Ok(text) => serde_json::from_str(&text)
.map(Some)
.map_err(|e| format!("{} is not a temporary worktree's record: {e}", file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("cannot read {}: {e}", file.display())),
}
}
struct Source {
path: String,
here: PathBuf,
repo: PathBuf,
commit: String,
}
fn sources(source: &Path, main: &Path, of: &str, project: &str) -> Result<Vec<Source>, String> {
let mut found = vec![Source {
path: String::new(),
here: source.to_path_buf(),
repo: main.to_path_buf(),
commit: head_of(source)?,
}];
let mut refused = Vec::new();
let mut index = 0;
while index < found.len() {
let parent_path = found[index].path.clone();
let parent_here = found[index].here.clone();
let parent_repo = found[index].repo.clone();
index += 1;
for (path, _) in gitlinks(&parent_here)? {
let shown = if parent_path.is_empty() { path.clone() } else { format!("{parent_path}/{path}") };
let here = parent_here.join(&path);
let repo = parent_repo.join(&path);
if !here.join(".git").exists() {
refused.push(format!(" {shown}: not checked out in {of}, so a snapshot of it would not be {of}"));
continue;
}
if is_broken(&here)? {
refused.push(format!(" {shown}: git no longer knows this checkout — `wsb worktree migrate {project}/{of}` repairs it"));
continue;
}
if !repo.join(".git").exists() || common_dir(&here)? != common_dir(&repo)? {
refused.push(format!(
" {shown}: a separate clone, not a worktree of main's repository — \
`wsb worktree migrate {project}/{of}` first"
));
continue;
}
found.push(Source {
path: shown,
commit: head_of(&here)?,
here,
repo,
});
}
}
if !refused.is_empty() {
return Err(format!("no snapshot of {project}/{of} is taken:\n{}", refused.join("\n")));
}
Ok(found)
}
pub fn snapshot(target: &Target, of: &str, working: bool) -> Result<Vec<Checkout>, String> {
let path = target.path();
let main = target.main();
let project = target.project.name().to_string();
if of != wsb_prj::MAIN && !wsb_prj::is_valid_name(of) {
return Err(format!("'{of}' is not a worktree name"));
}
if of == target.name {
return Err(format!("{target} cannot be a snapshot of itself"));
}
let source = target.project.dir().join(wsb_prj::worktree_folder(of));
if !source.is_dir() {
return Err(format!("{project}/{of} is not there: {} does not exist", source.display()));
}
let earlier = if path.exists() {
match temporary(&path)? {
Some(earlier) if earlier.of == of => Some(earlier),
Some(earlier) => {
return Err(format!(
"{target} is a snapshot of {project}/{}, not of {project}/{of}; it is left as it is",
earlier.of
))
}
None => {
return Err(format!(
"{target} is a worktree of its own at {}, not a temporary one; it is left as it is",
path.display()
))
}
}
} else {
None
};
let sources = sources(&source, &main, of, &project)?;
let reason = format!("wsb worktree snapshot {target}");
let mut made = Vec::new();
let outcome = (|| -> Result<Vec<Checkout>, String> {
if let Some(earlier) = &earlier {
let mut gone: Vec<&String> = earlier
.commits
.keys()
.filter(|shown| shown.as_str() != "." && !sources.iter().any(|s| &s.path == *shown))
.collect();
gone.sort_by_key(|shown| std::cmp::Reverse(shown.matches('/').count()));
for shown in gone {
let checkout = path.join(shown);
if !checkout.join(".git").exists() {
continue;
}
let repo = main.join(shown);
let arg = path_arg(&checkout)?.to_string();
let _ = git(&repo, &["worktree", "unlock", &arg]);
git(&repo, &["worktree", "remove", "--force", "--force", &arg])?;
}
}
let mut checkouts = Vec::new();
let mut commits = BTreeMap::new();
for source in &sources {
let destination = if source.path.is_empty() { path.clone() } else { path.join(&source.path) };
let arg = path_arg(&destination)?.to_string();
if destination.join(".git").exists() {
if common_dir(&destination)? != common_dir(&source.repo)? {
return Err(format!(
"{} is a checkout of another repository than {}; remove {target} and take it again",
destination.display(),
source.repo.display()
));
}
git(&destination, &["checkout", "--quiet", "--force", "--detach", &source.commit])?;
git(&destination, &["clean", "--quiet", "-fd"])?;
} else {
if destination.exists()
&& std::fs::read_dir(&destination)
.map_err(|e| format!("cannot read {}: {e}", destination.display()))?
.next()
.is_some()
{
return Err(format!(
"{} holds files and is no checkout of {}; remove {target} and take it again",
destination.display(),
source.repo.display()
));
}
forget_stale_registration(&source.repo, &destination)?;
git(&source.repo, &["worktree", "add", "--quiet", "--detach", &arg, &source.commit])?;
made.push(Made {
repo: source.repo.clone(),
path: destination.clone(),
created_branch: None,
});
git(&source.repo, &["worktree", "lock", "--reason", &reason, &arg])?;
}
if working {
overlay(&source.here, &destination)?;
}
copy_wsb_markers(&source.here, &destination)?;
commits.insert(if source.path.is_empty() { ".".to_string() } else { source.path.clone() }, source.commit.clone());
checkouts.push(Checkout {
path: source.path.clone(),
branch: None,
commit: source.commit.clone(),
});
}
let record = Temporary {
of: of.to_string(),
working,
commits,
};
let file = git_dir(&path)?.join(TEMPORARY);
let text = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
std::fs::write(&file, text).map_err(|e| format!("cannot write {}: {e}", file.display()))?;
Ok(checkouts)
})();
match outcome {
Ok(checkouts) => Ok(checkouts),
Err(error) if earlier.is_none() => Err(match undo(made) {
Ok(()) => format!("{error}\n nothing of {target} was left behind"),
Err(stuck) => format!("{error}\n and undoing what was made failed: {stuck}"),
}),
Err(error) => Err(format!("{error}\n {target} is part-way refreshed; take it again")),
}
}
fn overlay(from: &Path, to: &Path) -> Result<(), String> {
let changed = git(from, &["diff", "--name-only", "-z", "HEAD", "--ignore-submodules=all"])?;
let untracked = git(from, &["ls-files", "--others", "--exclude-standard", "-z"])?;
for relative in changed.split('\0').chain(untracked.split('\0')).filter(|p| !p.is_empty()) {
let source = from.join(relative);
let destination = to.join(relative);
let meta = match std::fs::symlink_metadata(&source) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(&destination).is_ok() {
std::fs::remove_file(&destination)
.map_err(|e| format!("cannot remove {}: {e}", destination.display()))?;
}
continue;
}
Err(e) => return Err(format!("cannot read {}: {e}", source.display())),
};
if meta.is_dir() {
continue;
}
copy_file(&source, &destination, &meta)?;
}
Ok(())
}
fn copy_wsb_markers(from: &Path, to: &Path) -> Result<(), String> {
for name in ["state.json", "templates"] {
let source = from.join(".wsb").join(name);
let destination = to.join(".wsb").join(name);
if std::fs::symlink_metadata(&destination).is_ok_and(|meta| meta.is_dir()) {
std::fs::remove_dir_all(&destination)
.map_err(|e| format!("cannot remove {}: {e}", destination.display()))?;
}
match std::fs::symlink_metadata(&source) {
Ok(meta) if meta.is_dir() => copy_tree(&source, &destination)?,
Ok(meta) => copy_file(&source, &destination, &meta)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(&destination).is_ok() {
std::fs::remove_file(&destination)
.map_err(|e| format!("cannot remove {}: {e}", destination.display()))?;
}
}
Err(e) => return Err(format!("cannot read {}: {e}", source.display())),
}
}
Ok(())
}
fn copy_tree(from: &Path, to: &Path) -> Result<(), String> {
std::fs::create_dir_all(to).map_err(|e| format!("cannot create {}: {e}", to.display()))?;
for entry in std::fs::read_dir(from).map_err(|e| format!("cannot read {}: {e}", from.display()))? {
let entry = entry.map_err(|e| format!("cannot read {}: {e}", from.display()))?;
let meta = std::fs::symlink_metadata(entry.path())
.map_err(|e| format!("cannot read {}: {e}", entry.path().display()))?;
let destination = to.join(entry.file_name());
if meta.is_dir() {
copy_tree(&entry.path(), &destination)?;
} else {
copy_file(&entry.path(), &destination, &meta)?;
}
}
Ok(())
}
fn copy_file(from: &Path, to: &Path, meta: &std::fs::Metadata) -> Result<(), String> {
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
if std::fs::symlink_metadata(to).is_ok() {
std::fs::remove_file(to).map_err(|e| format!("cannot replace {}: {e}", to.display()))?;
}
if meta.file_type().is_symlink() {
let pointed = std::fs::read_link(from).map_err(|e| format!("cannot read {}: {e}", from.display()))?;
return crate::platform::symlink_file(&pointed, to)
.map_err(|e| format!("cannot link {}: {e}", to.display()));
}
std::fs::copy(from, to).map_err(|e| format!("cannot copy {} to {}: {e}", from.display(), to.display()))?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Listed {
pub project: String,
pub name: String,
pub path: String,
pub branch: Option<String>,
pub shared: Vec<String>,
pub cloned: Vec<String>,
pub missing: Vec<String>,
pub broken: Vec<String>,
pub temporary: Option<Temporary>,
}
pub fn list(project: &wsb_prj::Project) -> Result<Vec<Listed>, String> {
let main = project.main_worktree();
let mut listed = Vec::new();
for worktree in project.worktrees().map_err(|e| e.to_string())? {
let mut entry = Listed {
project: project.name().to_string(),
name: worktree.name.clone(),
path: worktree.path.display().to_string(),
branch: branch_of(&worktree.path)?,
shared: Vec::new(),
cloned: Vec::new(),
missing: Vec::new(),
broken: Vec::new(),
temporary: temporary(&worktree.path)?,
};
walk(&worktree.path, &main, "", &mut entry)?;
listed.push(entry);
}
Ok(listed)
}
fn walk(here: &Path, there: &Path, prefix: &str, entry: &mut Listed) -> Result<(), String> {
for (path, _) in gitlinks(here)? {
let shown = format!("{prefix}{path}");
let checkout = here.join(&path);
if !checkout.join(".git").exists() {
entry.missing.push(shown);
continue;
}
if is_broken(&checkout)? {
entry.broken.push(shown);
continue;
}
let theirs = there.join(&path);
if same_path(&checkout, &theirs) || (theirs.join(".git").exists() && common_dir(&checkout)? == common_dir(&theirs)?) {
entry.shared.push(shown.clone());
} else {
entry.cloned.push(shown.clone());
}
walk(&checkout, &theirs, &format!("{shown}/"), entry)?;
}
Ok(())
}
pub fn migrate(target: &Target) -> Result<Vec<Checkout>, String> {
let path = target.path();
let main = target.main();
if !path.is_dir() {
return Err(format!("{target} is not there: {} does not exist", path.display()));
}
if common_dir(&path)? != common_dir(&main)? {
return Err(format!(
"{} is not a worktree of {}; only its submodules are migrated, and it would \
have to be one",
path.display(),
main.display()
));
}
let branch = branch_of(&path)?.unwrap_or_else(|| default_branch(&target.name));
struct Clone {
member: Member,
branch: Option<String>,
commit: String,
git_dir: PathBuf,
}
let mut clones = Vec::new();
let mut refused = Vec::new();
let mut orphans = Vec::new();
for member in members(&path, &main)? {
if member.path.is_empty() {
continue;
}
if member.broken {
orphans.push(member);
continue;
}
if !member.there.join(".git").exists() {
refused.push(format!(" {}: main has not checked it out", member.path));
continue;
}
if common_dir(&member.here)? == common_dir(&member.there)? {
continue;
}
let status = git(&member.here, &["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"])?;
if !status.is_empty() {
refused.push(format!(" {}: {} uncommitted change(s)", member.path, status.lines().count()));
continue;
}
clones.push(Clone {
branch: branch_of(&member.here)?,
commit: head_of(&member.here)?,
git_dir: git_dir(&member.here)?,
member,
});
}
if !refused.is_empty() {
return Err(format!("{target} is not migrated:\n{}", refused.join("\n")));
}
let prefix = format!("refs/salvage/{}", target.name);
for clone in clones.iter().rev() {
let source = path_arg(&clone.member.here)?;
git(
&clone.member.there,
&["fetch", "--quiet", "--no-tags", source, &format!("+refs/heads/*:{prefix}/heads/*"), &format!("+HEAD:{prefix}/HEAD")],
)?;
if !has_commit(&clone.member.there, &clone.commit)? {
return Err(format!(
"{} did not receive {} from the clone at {}; nothing was removed",
clone.member.there.display(),
clone.commit,
clone.member.here.display()
));
}
}
for clone in &clones {
let nested = clones.iter().any(|outer| {
outer.member.depth < clone.member.depth
&& clone.member.path.starts_with(&format!("{}/", outer.member.path))
});
if !nested {
std::fs::remove_dir_all(&clone.member.here)
.map_err(|e| format!("cannot remove the clone at {}: {e}", clone.member.here.display()))?;
std::fs::create_dir_all(&clone.member.here)
.map_err(|e| format!("cannot recreate {}: {e}", clone.member.here.display()))?;
}
}
let own = git_dir(&path)?;
for clone in &clones {
if clone.git_dir.exists() {
std::fs::remove_dir_all(&clone.git_dir)
.map_err(|e| format!("cannot remove {}: {e}", clone.git_dir.display()))?;
}
let mut parent = clone.git_dir.parent();
while let Some(folder) = parent {
if folder == own || !folder.starts_with(&own) {
break;
}
let empty = std::fs::read_dir(folder).map(|mut entries| entries.next().is_none()).unwrap_or(false);
if !empty {
break;
}
std::fs::remove_dir(folder).map_err(|e| format!("cannot remove {}: {e}", folder.display()))?;
parent = folder.parent();
}
}
let aside = target
.project
.state_dir()
.join("orphaned")
.join(&target.name)
.join(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs()
.to_string(),
);
let mut moved = Vec::new();
for orphan in &orphans {
let destination = aside.join(&orphan.path);
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
std::fs::rename(&orphan.here, &destination).map_err(|e| {
format!("cannot move {} aside to {}: {e}", orphan.here.display(), destination.display())
})?;
std::fs::create_dir_all(&orphan.here)
.map_err(|e| format!("cannot recreate {}: {e}", orphan.here.display()))?;
moved.push((orphan.path.clone(), orphan.here.clone(), destination));
}
let reason = format!("wsb worktree {target}");
let mut placed = Vec::new();
place_again(&main, &path, "", &branch, &reason, &clones.iter().map(|c| (c.member.path.clone(), c.branch.clone(), c.commit.clone())).collect::<Vec<_>>(), &mut placed)?;
let mut kept = Vec::new();
for (shown, checkout, orphan) in moved {
let differing = differing_files(&orphan, &checkout)?;
if differing.is_empty() {
std::fs::remove_dir_all(&orphan).map_err(|e| format!("cannot remove {}: {e}", orphan.display()))?;
} else {
kept.push(format!(
" {shown}: {} file(s) differ from the commit it was put back on; the old checkout \
is kept at {}",
differing.len(),
orphan.display()
));
}
}
let state = target.project.state_dir();
let mut folder = Some(aside.as_path());
while let Some(dir) = folder {
if dir == state || !dir.starts_with(&state) || !dir.is_dir() {
break;
}
let empty = std::fs::read_dir(dir).map(|mut e| e.next().is_none()).unwrap_or(false);
if !empty {
break;
}
std::fs::remove_dir(dir).map_err(|e| format!("cannot remove {}: {e}", dir.display()))?;
folder = dir.parent();
}
if !kept.is_empty() {
crate::output::message(
crate::output::record::Level::Warn,
"worktree-orphan-kept",
format!("{target} was repaired; look at what the old checkouts held:\n{}", kept.join("\n")),
);
}
Ok(placed)
}
fn place_again(
there: &Path,
here: &Path,
prefix: &str,
worktree_branch: &str,
reason: &str,
salvaged: &[(String, Option<String>, String)],
placed: &mut Vec<Checkout>,
) -> Result<(), String> {
for (path, pinned) in gitlinks(here)? {
let shown = format!("{prefix}{path}");
let repo = there.join(&path);
let destination = here.join(&path);
let arg = path_arg(&destination)?.to_string();
if destination.join(".git").exists() {
place_again(&repo, &destination, &format!("{shown}/"), worktree_branch, reason, salvaged, placed)?;
continue;
}
if !repo.join(".git").exists() {
return Err(format!(
"{shown} is not checked out in main, so its repository cannot be shared; run \
`git submodule update --init --recursive` in {} and migrate again",
there.display()
));
}
forget_stale_registration(&repo, &destination)?;
let (branch, commit) = match salvaged.iter().find(|(p, _, _)| p == &shown) {
Some((_, branch, commit)) => (branch.clone(), commit.clone()),
None => (Some(worktree_branch.to_string()), pinned),
};
match &branch {
Some(branch) if holder_of(&repo, branch)?.is_none() => {
if has_local_branch(&repo, branch)? {
let tip = git(&repo, &["rev-parse", &format!("refs/heads/{branch}")])?;
if tip == commit {
git(&repo, &["worktree", "add", "--quiet", &arg, branch])?;
} else {
git(&repo, &["worktree", "add", "--quiet", "--detach", &arg, &commit])?;
}
} else {
git(&repo, &["worktree", "add", "--quiet", "-b", branch, &arg, &commit])?;
}
}
_ => {
git(&repo, &["worktree", "add", "--quiet", "--detach", &arg, &commit])?;
}
}
git(&repo, &["worktree", "lock", "--reason", reason, &arg])?;
placed.push(Checkout {
path: shown.clone(),
branch: branch_of(&destination)?,
commit: head_of(&destination)?,
});
place_again(&repo, &destination, &format!("{shown}/"), worktree_branch, reason, salvaged, placed)?;
}
Ok(())
}
fn differing_files(old: &Path, new: &Path) -> Result<Vec<PathBuf>, String> {
let mut differing = Vec::new();
let mut pending = vec![PathBuf::new()];
while let Some(relative) = pending.pop() {
let dir = old.join(&relative);
let entries = std::fs::read_dir(&dir).map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
if entry.file_name() == ".git" {
continue;
}
let inside = relative.join(entry.file_name());
let kind = entry.file_type().map_err(|e| format!("cannot read {}: {e}", entry.path().display()))?;
if kind.is_dir() {
pending.push(inside);
} else {
let theirs = new.join(&inside);
let same = theirs.is_file()
&& std::fs::read(entry.path()).map_err(|e| e.to_string())?
== std::fs::read(&theirs).map_err(|e| e.to_string())?;
if !same {
differing.push(inside);
}
}
}
}
differing.sort();
Ok(differing)
}
#[cfg(test)]
#[path = "worktree_tests.rs"]
mod tests;