use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use wsb_prj::settings::{relative_path, ProjectSettings, RepoSettings, VersionSettings};
use crate::annotations::{Link, Note, ProjectId, Ref};
#[derive(Debug, Default)]
pub struct Report {
pub done: Vec<String>,
pub refused: Vec<String>,
}
impl Report {
fn take(&mut self, what: &str, outcome: Result<Vec<String>>) {
match outcome {
Ok(lines) => self.done.extend(lines),
Err(e) => self.refused.push(format!("{what}: {e:#}")),
}
}
}
const OLD_ENTRIES: [&str; 14] = [
"state.json",
"wsb.db",
"wsb.db-wal",
"wsb.db-shm",
"project.db",
"project.db-wal",
"project.db-shm",
"logs",
"templates",
"disposable",
"extensions",
"backups",
"workspace.lock",
"workspace.writers",
];
fn is_old_exclusion(line: &str) -> bool {
matches!(
line.trim(),
".wsb/*" | "!.wsb/stamp.json" | "!.wsb/test.json" | "!.wsb/peer.json" | ".wsb" | ".wsb/" | "/.wsb" | "/.wsb/"
)
}
pub fn leftovers_in(repo_root: &Path) -> Result<Vec<String>> {
let dir = wsb_prj::settings::dir_of(repo_root);
let mut found: Vec<String> = OLD_ENTRIES
.iter()
.filter(|name| dir.join(name).exists())
.map(|name| format!("{} is an older wsb's", dir.join(name).display()))
.collect();
if let Some(exclude) = exclude_file(repo_root)? {
let text = read_if_there(&exclude)?.unwrap_or_default();
if text.lines().any(is_old_exclusion) {
found.push(format!("{} hides .wsb/, which is committed now", exclude.display()));
}
}
Ok(found)
}
pub fn refuse_while_a_daemon_runs(machine: &Path) -> Result<()> {
let path = machine.join("daemon.json");
let Some(text) = read_if_there(&path)? else {
return Ok(());
};
let pid = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|value| value.get("pid").and_then(|pid| pid.as_u64()))
.and_then(|pid| u32::try_from(pid).ok());
if let Some(pid) = pid {
if crate::ops::process::is_alive(pid) {
bail!(
"the daemon (pid {pid}) is running and holds the databases this moves; stop it with \
`wsb daemon stop` and run `wsb migrate` again"
);
}
}
Ok(())
}
pub fn migrate_machine(workshop: Option<&wsb_prj::Workshop>) -> Result<Report> {
let mut report = Report::default();
let machine = crate::daemon::machine_dir().map_err(anyhow::Error::msg)?;
refuse_while_a_daemon_runs(&machine)?;
if workshop.is_some() {
report.take("the machine's old ~/.wsb", adopt_home_state(&machine));
}
report.take("the old databases", consolidate_databases(workshop, &machine));
report.take("the service record", migrate_service_record(&machine));
Ok(report)
}
pub fn migrate_workshop(workshop: &wsb_prj::Workshop) -> Result<Report> {
let mut report = migrate_machine(Some(workshop))?;
let machine = crate::daemon::machine_dir().map_err(anyhow::Error::msg)?;
for workspace in workshop.workspaces()? {
for worktree in workspace.worktrees()? {
let label = format!("{}/{}", workspace.name(), worktree.name);
report.take(&label, migrate_checkout(&worktree.path, &machine));
}
}
for workspace in workshop.workspaces()? {
report.take(
&format!("the state of {}", workspace.name()),
migrate_workspace_state(workshop, &workspace),
);
}
report.take("pins", migrate_pins(workshop));
Ok(report)
}
pub fn migrate_outside(checkout: &Path) -> Result<Report> {
let mut report = Report::default();
let machine = crate::daemon::machine_dir().map_err(anyhow::Error::msg)?;
refuse_while_a_daemon_runs(&machine)?;
report.take(&checkout.display().to_string(), migrate_checkout(checkout, &machine));
Ok(report)
}
fn migrate_service_record(machine: &Path) -> Result<Vec<String>> {
let path = crate::daemon::service::record_path(machine);
let Some(text) = read_if_there(&path)? else {
return Ok(Vec::new());
};
let mut record: serde_json::Value =
serde_json::from_str(&text).with_context(|| format!("{} is not JSON", path.display()))?;
let object = record
.as_object_mut()
.with_context(|| format!("{} is not a service record", path.display()))?;
let Some(root) = object.remove("workspace_root") else {
return Ok(Vec::new());
};
if object.contains_key("worktree_root") {
bail!(
"{} names the daemon's worktree twice, as workspace_root and worktree_root; install the \
service again with `wsb daemon install`",
path.display()
);
}
object.insert("worktree_root".to_string(), root);
if let Some(arguments) = object.get_mut("arguments").and_then(|a| a.as_array_mut()) {
for argument in arguments.iter_mut() {
if argument.as_str() == Some("--workspace") {
*argument = serde_json::Value::String("--worktree".to_string());
}
}
}
let written = format!("{}\n", serde_json::to_string_pretty(&record)?);
let temporary = path.with_extension("json.migrating");
std::fs::write(&temporary, written).with_context(|| format!("writing {}", temporary.display()))?;
std::fs::rename(&temporary, &path).with_context(|| format!("replacing {}", path.display()))?;
Ok(vec![format!(
"rewrote {} for this wsb; render the service again with `wsb daemon install`",
path.display()
)])
}
fn migrate_checkout(worktree_root: &Path, machine: &Path) -> Result<Vec<String>> {
let mut done = Vec::new();
let dir = wsb_prj::settings::dir_of(worktree_root);
let old_dbs = [dir.join("wsb.db"), dir.join("project.db")];
let mut row = None;
for db in &old_dbs {
if row.is_none() {
row = old_project_row(db)?;
}
}
let mut repos = vec![worktree_root.to_path_buf()];
if let Ok(tree) = crate::fleet::git::SubmoduleTree::discover_allow_empty(worktree_root) {
repos.extend(tree.top_down().iter().filter(|node| node.initialized).map(|node| node.abs_path.clone()));
}
for repo in &repos {
done.extend(settings_of(repo, worktree_root, row.as_ref())?);
done.extend(unhide(repo)?);
}
let state = crate::local_state::LocalState::of(worktree_root)?;
for db in &old_dbs {
done.extend(move_database(worktree_root, db, &state)?);
}
for repo in &repos {
done.extend(move_local_files(repo, &state, machine)?);
}
Ok(done)
}
#[derive(Debug, Clone)]
struct OldProjectRow {
major: u32,
version_file: String,
detect_manifests: bool,
manifests: Vec<String>,
}
fn old_project_row(db: &Path) -> Result<Option<OldProjectRow>> {
if !db.exists() {
return Ok(None);
}
let conn = rusqlite::Connection::open_with_flags(db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("opening {}", db.display()))?;
let has: bool = conn.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects')",
[],
|row| row.get(0),
)?;
if !has {
return Ok(None);
}
let mut rows = conn.prepare("SELECT major_version, version_file, auto_detect_project_files, project_files FROM projects")?;
let found: Vec<(i64, String, i64, Option<String>)> = rows
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?
.collect::<rusqlite::Result<_>>()?;
match found.as_slice() {
[] => Ok(None),
[(major, version_file, detect, manifests)] => Ok(Some(OldProjectRow {
major: u32::try_from(*major).with_context(|| format!("{} holds a major of {major}", db.display()))?,
version_file: version_file.clone(),
detect_manifests: *detect != 0,
manifests: match manifests {
Some(text) => serde_json::from_str(text)
.with_context(|| format!("{} holds project files that are not a list: {text}", db.display()))?,
None => Vec::new(),
},
})),
several => bail!(
"{} holds {} project rows; an older wsb only ever wrote one, so which is meant cannot be told",
db.display(),
several.len()
),
}
}
#[derive(Debug, Deserialize)]
struct OldState {
#[serde(default)]
version_suffix: Option<String>,
#[serde(default)]
project_name: Option<String>,
#[serde(default)]
wstemplate_entries: Vec<OldEntry>,
#[serde(default)]
ignored_submodules: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct OldEntry {
alias: String,
root: PathBuf,
}
fn settings_of(repo: &Path, worktree_root: &Path, row: Option<&OldProjectRow>) -> Result<Vec<String>> {
let state_path = wsb_prj::settings::dir_of(repo).join("state.json");
let Some(text) = read_if_there(&state_path)? else {
return Ok(Vec::new());
};
let old: OldState =
serde_json::from_str(&text).with_context(|| format!("{} is not a state file wsb wrote", state_path.display()))?;
let default_name = wsb_prj::repository_name(repo)?;
let (alias, templates_root) = match old.wstemplate_entries.as_slice() {
[] => (crate::state::WstemplateEngine::derive_alias(Path::new(&default_name)), ".".to_string()),
[entry] => {
let root = if entry.root.is_absolute() {
relative_path(&entry.root, repo)?
} else {
entry.root.to_string_lossy().replace('\\', "/")
};
(entry.alias.clone(), root)
}
several => bail!("{} names {} template entries; a repository has one", state_path.display(), several.len()),
};
let from_file = crate::state::read_major_version_from_file(repo);
let major = match row {
Some(row) if row.major > 0 => row.major,
_ => from_file.unwrap_or(0),
};
let version = VersionSettings {
major,
file: row.map(|r| r.version_file.clone()).unwrap_or_else(|| VersionSettings::DEFAULT_FILE.to_string()),
suffix: old.version_suffix.filter(|suffix| !suffix.is_empty()),
detect_manifests: row.map(|r| r.detect_manifests).unwrap_or(true),
manifests: row.map(|r| r.manifests.clone()).unwrap_or_default(),
};
let wanted = RepoSettings {
name: old.project_name.filter(|name| !name.trim().is_empty()).unwrap_or(default_name),
alias,
templates_root,
version,
};
let mut done = Vec::new();
match RepoSettings::load(repo)? {
Some(held) if held == wanted => {}
Some(held) => bail!(
"{} is already there and says {:?}, while {} says {:?}; keep one, remove the other, and run again",
RepoSettings::path(repo).display(),
held,
state_path.display(),
wanted
),
None => {
wanted.save(repo)?;
done.push(format!("wrote {} (commit it)", RepoSettings::path(repo).display()));
}
}
if repo == worktree_root && !old.ignored_submodules.is_empty() {
let mut project = ProjectSettings::load(repo)?;
if project.ignored_submodules != old.ignored_submodules {
if !project.ignored_submodules.is_empty() {
bail!(
"{} already ignores {:?}, while {} ignores {:?}; keep one and run again",
ProjectSettings::path(repo).display(),
project.ignored_submodules,
state_path.display(),
old.ignored_submodules
);
}
project.ignored_submodules = old.ignored_submodules;
project.save(repo)?;
done.push(format!("wrote the ignored submodules into {} (commit it)", ProjectSettings::path(repo).display()));
}
}
std::fs::remove_file(&state_path).with_context(|| format!("removing {}", state_path.display()))?;
done.push(format!("removed {}", state_path.display()));
Ok(done)
}
fn unhide(repo: &Path) -> Result<Vec<String>> {
let Some(exclude) = exclude_file(repo)? else {
return Ok(Vec::new());
};
let Some(text) = read_if_there(&exclude)? else {
return Ok(Vec::new());
};
if !text.lines().any(is_old_exclusion) {
return Ok(Vec::new());
}
let kept: Vec<&str> = text.lines().filter(|line| !is_old_exclusion(line)).collect();
let mut rewritten = kept.join("\n");
if !rewritten.is_empty() {
rewritten.push('\n');
}
std::fs::write(&exclude, rewritten).with_context(|| format!("writing {}", exclude.display()))?;
Ok(vec![format!("{} no longer hides .wsb/", exclude.display())])
}
fn exclude_file(root: &Path) -> Result<Option<PathBuf>> {
if !root.join(".git").exists() {
return Ok(None);
}
let output = crate::git_backend::git_command()
.arg("-C")
.arg(root)
.args(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"])
.output()
.with_context(|| format!("cannot run git in {}", root.display()))?;
if !output.status.success() {
bail!(
"git cannot say where {}'s exclude file is: {}",
root.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(Some(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim())))
}
fn move_database(worktree_root: &Path, old_db: &Path, state: &crate::local_state::LocalState) -> Result<Vec<String>> {
if !old_db.exists() {
return Ok(Vec::new());
}
let mut done = Vec::new();
let old = rusqlite::Connection::open_with_flags(old_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("opening {}", old_db.display()))?;
let table_exists = |name: &str| -> Result<bool> {
Ok(old.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
[name],
|row| row.get(0),
)?)
};
if table_exists("workbench_state")? {
let rows: Vec<(String, String)> = old
.prepare("SELECT workspace_root, state_json FROM workbench_state")?
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
let here = crate::platform::canonicalize(worktree_root).unwrap_or_else(|_| worktree_root.to_path_buf());
let layout = match rows.as_slice() {
[] => None,
[(_, layout)] => Some(layout.clone()),
several => several
.iter()
.find(|(root, _)| Path::new(root) == here || Path::new(root) == worktree_root)
.map(|(_, layout)| layout.clone()),
};
if let Some(layout) = layout {
let old: serde_json::Value = serde_json::from_str(&layout)
.with_context(|| format!("{} holds a workbench layout that is not JSON", old_db.display()))?;
let folders = old.get("expanded_paths").cloned().unwrap_or_else(|| serde_json::json!([]));
let kept = serde_json::json!({ "expanded_paths": folders }).to_string();
crate::store::open_for_migration()?.get_connection()?.execute(
"INSERT INTO workbench_state (worktree, state_json) VALUES (?1, ?2) \
ON CONFLICT(worktree) DO NOTHING",
rusqlite::params![state.key(), &kept],
)?;
done.push(format!("kept the explorer's open folders in the workshop's database, as {}", state.key()));
}
}
for table in [
"notes", "note_links", "path_notes", "features", "tasks", "milestones", "directives", "dependencies", "surveys",
"work_sessions", "session_continuity_states", "session_knowledge_transfers",
] {
if table_exists(table)? {
let count: i64 = old.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0))?;
if count > 0 {
done.push(format!("dropped {count} row(s) of {table}, which this wsb does not keep"));
}
}
}
drop(old);
remove_database_files(old_db)?;
done.push(format!("removed {} (its test catalogue is rebuilt by the next scan)", old_db.display()));
Ok(done)
}
fn remove_database_files(db: &Path) -> Result<()> {
for suffix in ["", "-wal", "-shm"] {
let path = PathBuf::from(format!("{}{suffix}", db.display()));
if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
}
}
Ok(())
}
fn consolidate_databases(workshop: Option<&wsb_prj::Workshop>, machine: &Path) -> Result<Vec<String>> {
let mut done = Vec::new();
let store = crate::store::path()?;
let old_sessions = machine.join("wsb.db");
if old_sessions.exists() && old_sessions != store {
if store.exists() && holds_nothing(&store)? {
remove_database_files(&store)?;
done.push(format!("removed {}, made empty before the migration", store.display()));
}
if store.exists() {
bail!(
"{} and {} both exist: the first is the machine's sessions as an older wsb kept them, the \
second the workshop's database a newer one made before `wsb migrate` ran. Which holds what \
cannot be merged without losing stars and pins: stop the daemon, move {} aside (its \
sessions are read again from their transcripts), and run `wsb migrate` again",
old_sessions.display(),
store.display(),
store.display()
);
}
let conn = rusqlite::Connection::open(&old_sessions)
.with_context(|| format!("opening {}", old_sessions.display()))?;
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))?;
drop(conn);
if let Some(parent) = store.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::rename(&old_sessions, &store)
.with_context(|| format!("moving {} to {}", old_sessions.display(), store.display()))?;
remove_database_files(&old_sessions)?;
done.push(format!("moved {} to {}", old_sessions.display(), store.display()));
}
crate::store::open_for_migration()?;
wsb_github::Mirror::open(&store)?;
let mut others = vec![machine.join("machine.db")];
if let Some(workshop) = workshop {
others.push(workshop.state_dir().join("github.db"));
}
for old in others {
if old.exists() && old != store {
let copied = copy_tables(&store, &old)?;
remove_database_files(&old)?;
done.push(format!("copied {copied} row(s) of {} into {}", old.display(), store.display()));
}
}
Ok(done)
}
fn holds_nothing(db: &Path) -> Result<bool> {
let conn = rusqlite::Connection::open(db).with_context(|| format!("opening {}", db.display()))?;
let tables: Vec<String> = conn
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
AND name NOT IN ('refinery_schema_history', 'github_schema') \
AND name NOT LIKE '%\\_fts\\_%' ESCAPE '\\'",
)?
.query_map([], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?;
for table in tables {
let rows: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |row| row.get(0))?;
if rows > 0 {
return Ok(false);
}
}
Ok(true)
}
const RENAMED_COLUMNS: [(&str, &str); 1] = [("workspace_id", "shared_worktree")];
fn copy_tables(store: &Path, old: &Path) -> Result<usize> {
let conn = rusqlite::Connection::open(store).with_context(|| format!("opening {}", store.display()))?;
conn.busy_timeout(std::time::Duration::from_secs(30))?;
conn.execute("ATTACH DATABASE ?1 AS old", [old.to_string_lossy()])
.with_context(|| format!("opening {}", old.display()))?;
let held: Vec<(String, String)> = conn
.prepare(
"SELECT name, sql FROM old.sqlite_master WHERE type = 'table' \
AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'refinery_%' AND sql IS NOT NULL",
)?
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
let columns = |schema: &str, table: &str| -> Result<Vec<String>> {
Ok(conn
.prepare("SELECT name FROM pragma_table_info(?1, ?2)")?
.query_map([table, schema], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?)
};
let mut copied = 0;
for (name, sql) in held {
let present: bool = conn.query_row(
"SELECT EXISTS (SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = ?1)",
[&name],
|row| row.get(0),
)?;
if !present {
conn.execute_batch(&sql)
.with_context(|| format!("making {name} as {} defines it", old.display()))?;
}
let here = columns("main", &name)?;
let mut from = Vec::new();
let mut to = Vec::new();
for column in columns("old", &name)? {
let now = RENAMED_COLUMNS
.iter()
.find(|(was, _)| *was == column)
.map_or(column.as_str(), |(_, now)| now);
if !here.iter().any(|known| known == now) {
bail!(
"{}'s table {name} has a column {column} the store's {name} has no place for; \
nothing was copied from it",
old.display()
);
}
from.push(format!("\"{column}\""));
to.push(format!("\"{now}\""));
}
copied += conn
.execute(
&format!(
"INSERT OR IGNORE INTO main.\"{name}\" ({}) SELECT {} FROM old.\"{name}\"",
to.join(", "),
from.join(", ")
),
[],
)
.with_context(|| format!("copying {name} from {}", old.display()))?;
}
conn.execute("DETACH DATABASE old", [])?;
Ok(copied)
}
fn move_local_files(repo: &Path, state: &crate::local_state::LocalState, machine: &Path) -> Result<Vec<String>> {
let dir = wsb_prj::settings::dir_of(repo);
let mut done = Vec::new();
let logs = dir.join("logs");
if logs.is_dir() {
merge_into(&logs, &state.logs())?;
done.push(format!("moved {} to {}", logs.display(), state.logs().display()));
}
let disposable = dir.join("disposable");
if disposable.is_dir() {
let logs = disposable.join("logs");
if logs.is_dir() {
merge_into(&logs, &state.logs())?;
done.push(format!("moved {} to {}", logs.display(), state.logs().display()));
}
let left = entries(&disposable)?;
if !left.is_empty() {
bail!(
"{} holds {} as well as logs; it is not something to move for you: move it out of .wsb/ and run again",
disposable.display(),
left.join(", ")
);
}
std::fs::remove_dir(&disposable).with_context(|| format!("removing {}", disposable.display()))?;
}
let backups = dir.join("backups");
if backups.is_dir() {
let held = entries(&backups)?.len();
std::fs::remove_dir_all(&backups).with_context(|| format!("removing {}", backups.display()))?;
done.push(format!(
"removed {} ({held} backup(s) of an older wsb's checkout database, which nothing reads any more)",
backups.display()
));
}
let extensions = dir.join("extensions");
if extensions.is_dir() {
let target = machine.join(crate::extensions::DIR);
merge_into(&extensions, &target)?;
done.push(format!("moved {} to the machine's {}", extensions.display(), target.display()));
}
let templates = dir.join("templates");
if templates.is_dir() {
let held: Vec<String> = entries(&templates)?
.into_iter()
.filter(|name| name != "templates.json")
.collect();
if !held.is_empty() {
bail!(
"{} holds .tera templates ({}), which this wsb does not render: make each a .wstemplate beside \
its output, remove the folder, and run again",
templates.display(),
held.join(", ")
);
}
std::fs::remove_dir_all(&templates).with_context(|| format!("removing {}", templates.display()))?;
done.push(format!("removed {}, which held no templates", templates.display()));
}
let lock = dir.join("workspace.lock");
if lock.exists() {
bail!(
"{} is held — a snapshot may be under way. Run again once it is released, or delete it if its holder is gone",
lock.display()
);
}
let writers = dir.join("workspace.writers");
if writers.is_dir() {
let live = entries(&writers)?;
if !live.is_empty() {
bail!("{} registers running writers ({}); run again once they finish", writers.display(), live.join(", "));
}
std::fs::remove_dir(&writers).with_context(|| format!("removing {}", writers.display()))?;
}
Ok(done)
}
fn migrate_workspace_state(workshop: &wsb_prj::Workshop, workspace: &wsb_prj::Workspace) -> Result<Vec<String>> {
let mut done = Vec::new();
let old = workshop.state_dir().join("projects").join(workspace.name());
let new = workspace.state_dir();
let main = workspace.main_worktree();
for github in [old.join("github.json"), workspace.dir().join(".wsb").join("github.json")] {
if let Some(text) = read_if_there(&github)? {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OldBoards {
#[serde(default)]
boards: Vec<String>,
}
let listed: OldBoards =
serde_json::from_str(&text).with_context(|| format!("{} is not a board list", github.display()))?;
let mut project = ProjectSettings::load(&main)?;
let before = project.boards.len();
for board in listed.boards {
if !project.boards.iter().any(|held| held.eq_ignore_ascii_case(&board)) {
project.boards.push(board);
}
}
if project.boards.len() != before {
project.save(&main)?;
done.push(format!(
"wrote {}'s boards into {} (commit it)",
workspace.name(),
ProjectSettings::path(&main).display()
));
}
std::fs::remove_file(&github).with_context(|| format!("removing {}", github.display()))?;
done.push(format!("removed {}", github.display()));
}
}
let stray = workspace.dir().join(".wsb");
if stray.is_dir() {
merge_into(&stray, &new)?;
done.push(format!("moved what was left in {} to {}", stray.display(), new.display()));
}
if old.is_dir() {
merge_into(&old, &new)?;
done.push(format!("moved {} to {}", old.display(), new.display()));
}
let projects = workshop.state_dir().join("projects");
if projects.is_dir() && entries(&projects)?.is_empty() {
std::fs::remove_dir(&projects).with_context(|| format!("removing {}", projects.display()))?;
}
Ok(done)
}
fn adopt_home_state(machine: &Path) -> Result<Vec<String>> {
if std::env::var_os(crate::daemon::MACHINE_DIR_ENV).is_some_and(|v| !v.is_empty()) {
return Ok(Vec::new());
}
let home = crate::daemon::home_dir().map_err(anyhow::Error::msg)?;
crate::daemon::adopt_old_machine_dir(&home, machine).map_err(anyhow::Error::msg)
}
fn migrate_pins(workshop: &wsb_prj::Workshop) -> Result<Vec<String>> {
let conn = crate::store::open_for_migration()?.get_connection()?;
let exists: bool = conn.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pins_to_migrate')",
[],
|row| row.get(0),
)?;
if !exists {
return Ok(Vec::new());
}
type Pin = (String, String, Option<String>, String, Option<String>);
let pins: Vec<Pin> = conn
.prepare("SELECT session_id, agent, project, message_uuid, note FROM pins_to_migrate")?
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)))?
.collect::<rusqlite::Result<_>>()?;
let mut done = Vec::new();
let mut by_project: BTreeMap<String, Vec<Pin>> = BTreeMap::new();
for pin in pins {
let Some(project) = pin.2.clone() else {
bail!(
"a pin on session {} was made in no project, so there is no project to commit it in; \
unpin it with an older wsb, or keep that wsb to read it",
pin.0
);
};
by_project.entry(project).or_default().push(pin);
}
let machine_name = crate::daemon::machine_name().map_err(anyhow::Error::msg)?;
for (project, pins) in by_project {
let workspace = workshop
.workspace(&project)
.with_context(|| format!("{} pin(s) were made in {project}, which is not in this workshop", pins.len()))?;
let main = workspace.main_worktree();
let id = ProjectId::of_checkout(&main)?;
for (session, agent, _, uuid, note) in &pins {
let message = Ref::Message { agent: agent.clone(), session: session.clone(), uuid: uuid.clone() };
Link::new(message.clone(), Ref::Project(id.clone()), Some(crate::annotations::links::PINNED.to_string()))?
.write(&main)?;
if let Some(text) = note.as_deref().filter(|text| !text.trim().is_empty()) {
let note = Note::new("Pinned message", text, &machine_name)?;
note.write(&main)?;
Link::new(Ref::Note { id: note.id.clone() }, message, None)?.write(&main)?;
}
}
done.push(format!("wrote {} pin(s) as links into {} (commit them)", pins.len(), main.display()));
}
conn.execute("DROP TABLE pins_to_migrate", [])?;
Ok(done)
}
fn read_if_there(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
}
}
fn entries(dir: &Path) -> Result<Vec<String>> {
let mut names = Vec::new();
for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
names.push(entry.with_context(|| format!("cannot read {}", dir.display()))?.file_name().to_string_lossy().into_owned());
}
names.sort();
Ok(names)
}
fn merge_into(from: &Path, to: &Path) -> Result<()> {
fn clashes(from: &Path, to: &Path, found: &mut Vec<String>) -> Result<()> {
for name in entries(from)? {
let (source, target) = (from.join(&name), to.join(&name));
if source.is_dir() && target.is_dir() {
clashes(&source, &target, found)?;
} else if target.exists() {
let same = source.is_file()
&& target.is_file()
&& std::fs::read(&source).ok() == std::fs::read(&target).ok();
if !same {
found.push(format!("{} and {}", source.display(), target.display()));
}
}
}
Ok(())
}
fn moved(from: &Path, to: &Path) -> Result<()> {
std::fs::create_dir_all(to).with_context(|| format!("cannot create {}", to.display()))?;
for name in entries(from)? {
let (source, target) = (from.join(&name), to.join(&name));
if source.is_dir() && target.is_dir() {
moved(&source, &target)?;
} else if target.exists() {
std::fs::remove_file(&source).with_context(|| format!("removing {}", source.display()))?;
} else if std::fs::rename(&source, &target).is_err() {
copy_tree(&source, &target)?;
remove_any(&source)?;
}
}
std::fs::remove_dir(from).with_context(|| format!("removing {}", from.display()))
}
let mut found = Vec::new();
if to.exists() {
clashes(from, to, &mut found)?;
}
if !found.is_empty() {
bail!("both hold different files: {}; keep one of each and run again", found.join("; "));
}
moved(from, to)
}
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
if from.is_dir() {
std::fs::create_dir_all(to).with_context(|| format!("cannot create {}", to.display()))?;
for name in entries(from)? {
copy_tree(&from.join(&name), &to.join(&name))?;
}
Ok(())
} else {
std::fs::copy(from, to).with_context(|| format!("copying {} to {}", from.display(), to.display()))?;
Ok(())
}
}
fn remove_any(path: &Path) -> Result<()> {
if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
.with_context(|| format!("removing {}", path.display()))
}
#[cfg(test)]
mod tests;