use std::collections::BTreeMap;
use std::path::Path;
use crate::ledger::docs;
use crate::ledger::dolt::{self, text, Dolt, BRANCH, REMOTE};
use crate::ledger::git::Git;
use crate::ledger::location::{dolt_db_path, state_root_from_env, LedgerLocation, Repo};
use crate::ledger::migrate::TempWorktree;
use crate::ledger::push::{commit_changes, PushStatus};
use crate::ledger::store::check_docs_path;
use crate::ledger::store::dolt::{remote_tip, DoltStore};
use crate::ledger::{report_refresh, retry_note};
const DOCS_COMMIT: &str = "Move the documents out of Dolt";
const DROP_COMMIT: &str = "Move the documents to the tsk/docs branch";
pub fn run(cwd: &Path, dry_run: bool) -> Result<(), String> {
Dolt::new(".").run(["version"])?;
dolt::ensure_identity()?;
let repo = Repo::discover(cwd)?;
let state_root = state_root_from_env()?;
let location = repo.location(&state_root)?;
let store = DoltStore::open(cwd)?;
let (outcome, version) = store.refresh_data(false)?;
let outcome = report_refresh(outcome);
if version != dolt::LEGACY_LAYOUT_VERSION {
return Err("error: the documents are already on the tsk/docs branch".to_string());
}
let db = Dolt::new(dolt_db_path(&state_root, &repo.ensure_clone_id()?));
let ledger_tip = remote_tip(&repo.git, &location)?;
let documents = read_documents(&db)?;
eprintln!(
"note: read {} documents from the documents table in {}",
documents.len(),
outcome.path.display()
);
let created_worktree = !docs::worktree_path(&repo, &state_root)?.exists();
let fetched = docs::fetch(&repo, &state_root, &location, false)?;
let mut guard = TempWorktree {
git: repo.git.clone(),
path: fetched.path.clone(),
keep: !created_worktree,
};
if !fetched.pending.is_empty() {
eprintln!(
"note: {} holds {} commits that are not on {}'s {}; they are pushed with the documents",
fetched.path.display(),
fetched.pending.len(),
location.label(),
fetched.location.ref_name()
);
}
let docs_path = fetched.path.clone();
let written = write_files(&docs_path, &documents)?;
let wt = Git::new(&docs_path);
let committed = commit_changes(&wt, DOCS_COMMIT)?;
verify(&wt, &documents)?;
eprintln!(
"note: {} files written, {} already on the branch{}",
written,
documents.len() - written,
if committed { "; committed" } else { "" }
);
eprintln!(
"note: the documents read back identical from the {} branch",
location.docs_ref_name()
);
if dry_run {
eprintln!("note: dry run: the table is not dropped and nothing is pushed");
println!("dry-run:ok");
return Ok(());
}
check_unmoved(&repo, &location, &ledger_tip, false)?;
db.exec(&format!(
"drop table documents;\n{}",
dolt::upgrade_sql(dolt::DOCS_LAYOUT_VERSION)
))?;
db.run(["add", "-A"])?;
db.run(["commit", "-m", DROP_COMMIT])?;
guard.keep = true;
let mut on_retry =
|attempt: usize, status: &PushStatus| eprintln!("{}", retry_note(attempt, status));
docs::push(&repo, &location, &docs_path, DOCS_COMMIT, &mut on_retry).map_err(|e| {
format!(
"{}\n The table is dropped in {} and the documents are committed in {}.\n \
Re-run tsk ledger push to publish both",
e,
db.dir().display(),
docs_path.display()
)
})?;
check_unmoved(&repo, &location, &ledger_tip, true)?;
db.run(["push", REMOTE, BRANCH]).map_err(|e| {
format!(
"{}\n The documents are on {}. The drop is committed in {}.\n \
Re-run tsk ledger push to publish it",
e,
location.docs_ref_name(),
db.dir().display()
)
})?;
eprintln!(
"note: pushed {} and then {}",
location.docs_ref_name(),
location.ref_name()
);
println!("migrated-docs:{}", documents.len());
Ok(())
}
fn read_documents(db: &Dolt) -> Result<Vec<(String, String)>, String> {
let rows = db.rows(
"select path, body, length(path) as path_bytes, length(body) as body_bytes \
from documents order by path",
)?;
let mut found: BTreeMap<String, String> = BTreeMap::new();
for row in &rows {
let raw = text(row, "path").unwrap_or_default();
let body = text(row, "body").unwrap_or_default();
let path_bytes = dolt::integer(row, "path_bytes").unwrap_or(-1);
let body_bytes = dolt::integer(row, "body_bytes").unwrap_or(-1);
if path_bytes != raw.len() as i64 {
return Err(format!(
"error: the document path {:?} in the documents table is not valid UTF-8; nothing was changed",
raw
));
}
if body_bytes != body.len() as i64 {
return Err(format!(
"error: the document {} in the documents table is not valid UTF-8; nothing was changed",
raw
));
}
let path = check_docs_path(&raw).map_err(|e| {
format!(
"error: the document {} in the documents table cannot be a file on the tsk/docs branch ({}); nothing was changed",
raw,
e.trim_start_matches("error: ")
)
})?;
if found.insert(path.clone(), body).is_some() {
return Err(format!(
"error: two documents in the documents table name the file {}; nothing was changed",
path
));
}
}
let paths: Vec<&String> = found.keys().collect();
for path in &paths {
let prefix = format!("{}/", path);
if let Some(inner) = paths.iter().find(|other| other.starts_with(&prefix)) {
return Err(format!(
"error: the document {} cannot be a file: the document {} needs it as a directory; nothing was changed",
path, inner
));
}
}
Ok(found.into_iter().collect())
}
fn write_files(root: &Path, documents: &[(String, String)]) -> Result<usize, String> {
let mut to_write = Vec::new();
for (path, body) in documents {
let file = root.join(path);
if file.is_dir() {
return Err(format!(
"error: {} is a directory on the tsk/docs branch; the document {} cannot be written. Nothing was changed",
file.display(),
path
));
}
match std::fs::read(&file) {
Ok(existing) if existing == body.as_bytes() => {}
Ok(existing) => {
let seed = String::from_utf8(existing)
.map(|text| docs::is_seed(path, &text))
.unwrap_or(false);
if !seed {
return Err(format!(
"error: the tsk/docs branch already holds {} with different content from the documents table. Nothing was changed",
path
));
}
to_write.push((file, body));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => to_write.push((file, body)),
Err(e) => return Err(format!("error: could not read {}: {}", file.display(), e)),
}
}
for (file, body) in &to_write {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("error: could not create {}: {}", dir.display(), e))?;
}
std::fs::write(file, body)
.map_err(|e| format!("error: could not write {}: {}", file.display(), e))?;
}
Ok(to_write.len())
}
fn verify(wt: &Git, documents: &[(String, String)]) -> Result<(), String> {
let status = wt.run(["status", "--porcelain=v2", "-z"])?;
if !status.is_empty() {
return Err(
"error: the documents worktree holds uncommitted changes after the commit; nothing was pushed"
.to_string(),
);
}
for (path, body) in documents {
let spec = format!("HEAD:{}", path);
let (args, output) = wt.output(["cat-file", "blob", spec.as_str()])?;
if !output.status.success() {
return Err(crate::ledger::git::failure_message(&args, &output));
}
if output.stdout != body.as_bytes() {
return Err(format!(
"error: the document {} on the tsk/docs branch differs from the documents table; nothing was pushed",
path
));
}
}
Ok(())
}
fn check_unmoved(
repo: &Repo,
location: &LedgerLocation,
expected: &Option<String>,
docs_pushed: bool,
) -> Result<(), String> {
let now = remote_tip(&repo.git, location)?;
if &now == expected {
return Ok(());
}
Err(format!(
"error: {} on {} moved while the migration ran (it was {}, it is now {}); another writer pushed.\n {}",
location.ref_name(),
location.label(),
expected.as_deref().unwrap_or("absent"),
now.as_deref().unwrap_or("absent"),
if docs_pushed {
"The documents are on the tsk/docs branch and the drop is committed locally. Run tsk ledger push"
} else {
"Nothing was pushed and the table is not dropped. Re-run tsk ledger migrate-docs"
}
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_files_replaces_a_seed_and_refuses_other_content() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.md"), "# Ledger index\n").unwrap();
std::fs::write(dir.path().join("keep.md"), "same").unwrap();
let docs = vec![
("index.md".to_string(), "# Real\n".to_string()),
("keep.md".to_string(), "same".to_string()),
("new/n.md".to_string(), "".to_string()),
];
assert_eq!(write_files(dir.path(), &docs).unwrap(), 2);
assert_eq!(
std::fs::read_to_string(dir.path().join("index.md")).unwrap(),
"# Real\n"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("new/n.md")).unwrap(),
""
);
std::fs::write(dir.path().join("keep.md"), "edited").unwrap();
let err = write_files(dir.path(), &docs).unwrap_err();
assert!(err.contains("keep.md"), "{}", err);
assert!(err.contains("Nothing was changed"), "{}", err);
}
}