v3 : publication — un envoi refusé repart, un dépôt neuf reçoit l'historique
Douzième relecture (850f9c4 : ok), défauts anciens de publish.rs mis en
jeu par le nouveau dépôt :
- un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans
droit d'écriture) repart au passage suivant, au lieu d'une file marquée
faite avec un dépôt vide ;
- un changement de remote vers un dépôt neuf et vide lui envoie
l'historique ;
- au démarrage, ce qui attendait repart aussitôt (le délai croissant
pouvait atteindre 6 h).
trustinWallet montre l'état de la publication (en attente, dernier échec,
dernier commit) et offre « Publier maintenant ».
Boucle : 69 tests Rust, svelte-check, 9 vitest, e2e 13/13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,8 +137,9 @@ async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result<Stri
|
||||
}
|
||||
|
||||
/// The server's own clone: a cache of the remote, realigned on it before each
|
||||
/// commit so a push never needs a merge.
|
||||
async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
|
||||
/// commit so a push never needs a merge. Returns the clone and the commit the
|
||||
/// remote branch points to, if it exists.
|
||||
async fn checkout(state: &AppState) -> anyhow::Result<(PathBuf, Option<String>)> {
|
||||
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
|
||||
let dir = state.cfg.data_dir.join("textes");
|
||||
if !dir.join(".git").exists() {
|
||||
@@ -148,7 +149,8 @@ async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
|
||||
}
|
||||
git(&dir, state, &["remote", "set-url", "origin", &publish.remote]).await?;
|
||||
let has_remote_branch = git(&dir, state, &["ls-remote", "--heads", "origin", &publish.branch]).await?;
|
||||
if has_remote_branch.is_empty() {
|
||||
let remote_head = has_remote_branch.split_whitespace().next().map(str::to_owned);
|
||||
if remote_head.is_none() {
|
||||
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch]).await?;
|
||||
} else {
|
||||
// This clone is a cache: whatever a failed pass left is re-rendered from the database.
|
||||
@@ -156,14 +158,15 @@ async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
|
||||
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch, &format!("origin/{}", publish.branch)])
|
||||
.await?;
|
||||
}
|
||||
Ok(dir)
|
||||
Ok((dir, remote_head))
|
||||
}
|
||||
|
||||
async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<String>> {
|
||||
let st = docs::load_state(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let Some(path) = st.doc.publish_path.clone() else { return Ok(None) };
|
||||
let rendered = render(state, &st).await?;
|
||||
let dir = checkout(state).await?;
|
||||
let (dir, remote_head) = checkout(state).await?;
|
||||
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
|
||||
let target = dir.join(&path);
|
||||
tokio::fs::create_dir_all(&target).await?;
|
||||
for (name, content) in &rendered {
|
||||
@@ -176,7 +179,15 @@ async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<St
|
||||
git(&dir, state, &["add", "-A", "--", &path, "README.md"]).await?;
|
||||
let staged = git(&dir, state, &["diff", "--cached", "--name-only"]).await?;
|
||||
if staged.is_empty() {
|
||||
return Ok(None);
|
||||
// Nothing new — but a commit the remote never received still goes out: a
|
||||
// first push refused (a key without write access), or a new, empty
|
||||
// repository after a change of remote.
|
||||
let Ok(head) = git(&dir, state, &["rev-parse", "--verify", "-q", "HEAD"]).await else { return Ok(None) };
|
||||
if remote_head.as_deref() == Some(head.as_str()) {
|
||||
return Ok(None);
|
||||
}
|
||||
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
|
||||
return Ok(Some(head));
|
||||
}
|
||||
let reasons: Vec<String> =
|
||||
sqlx::query_scalar("SELECT reason FROM outbox WHERE doc_id = ? AND done_at IS NULL ORDER BY id")
|
||||
@@ -192,7 +203,6 @@ async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<St
|
||||
}
|
||||
}
|
||||
git(&dir, state, &["commit", "-q", "-m", &message]).await?;
|
||||
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
|
||||
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
|
||||
Ok(Some(git(&dir, state, &["rev-parse", "HEAD"]).await?))
|
||||
}
|
||||
@@ -225,11 +235,13 @@ async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result<String> {
|
||||
|
||||
/// On start, every published document is queued once more: a change that
|
||||
/// entered into force just before a restart, or a new rendering format, reaches
|
||||
/// the repository. Unchanged documents produce no commit.
|
||||
/// the repository. Unchanged documents produce no commit. What was waiting
|
||||
/// after failures is tried again at once (a key just added, a new remote).
|
||||
pub async fn resync(state: &AppState) -> anyhow::Result<()> {
|
||||
if state.cfg.publish.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
retry_now(state).await?;
|
||||
let now = util::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox (doc_id, reason, created_at, next_at)
|
||||
@@ -244,6 +256,39 @@ pub async fn resync(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything still waiting goes out at the next pass, backoff forgotten.
|
||||
pub async fn retry_now(state: &AppState) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE outbox SET next_at = ?, attempts = 0 WHERE done_at IS NULL")
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
state.publish_wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What administrators see of the publication in trustinWallet.
|
||||
pub async fn status(state: &AppState) -> anyhow::Result<serde_json::Value> {
|
||||
let pending: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL").fetch_one(&state.db).await?;
|
||||
let error: Option<String> = sqlx::query_scalar(
|
||||
"SELECT last_error FROM outbox WHERE done_at IS NULL AND last_error IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let last: Option<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT done_at, commit_sha FROM outbox WHERE done_at IS NOT NULL AND commit_sha IS NOT NULL
|
||||
ORDER BY done_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(json!({
|
||||
"pending": pending,
|
||||
"lastError": error,
|
||||
"lastPublishedAt": last.as_ref().map(|(at, _)| at.clone()),
|
||||
"lastCommit": last.and_then(|(_, sha)| sha),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Markdown per language + JSON. Only the units in force, in order.
|
||||
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let titles = util::parse_lang(&st.doc.title);
|
||||
|
||||
Reference in New Issue
Block a user