Add /sync git push command

This commit is contained in:
TheGeneralist 2026-02-04 12:41:55 +01:00
parent b45b199ee5
commit 37523a874b
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
3 changed files with 382 additions and 1 deletions

89
Cargo.lock generated
View file

@ -11,6 +11,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.21" version = "0.6.21"
@ -138,7 +147,9 @@ version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [ dependencies = [
"iana-time-zone",
"num-traits", "num-traits",
"windows-link",
] ]
[[package]] [[package]]
@ -589,6 +600,30 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@ -1015,6 +1050,7 @@ name = "readlater-bot"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"clap", "clap",
"env_logger", "env_logger",
"log", "log",
@ -1833,12 +1869,65 @@ version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"

View file

@ -5,6 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
env_logger = "0.11" env_logger = "0.11"
log = "0.4" log = "0.4"

View file

@ -1,10 +1,13 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use chrono::Local;
use clap::Parser; use clap::Parser;
use log::error; use log::error;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
@ -12,6 +15,7 @@ use serde::{Deserialize, Serialize};
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tempfile::NamedTempFile;
use uuid::Uuid; use uuid::Uuid;
const ACK_TTL_SECS: u64 = 5; const ACK_TTL_SECS: u64 = 5;
@ -29,6 +33,13 @@ struct Config {
resources_path: PathBuf, resources_path: PathBuf,
data_dir: PathBuf, data_dir: PathBuf,
retry_interval_seconds: Option<u64>, retry_interval_seconds: Option<u64>,
sync: Option<SyncConfig>,
}
#[derive(Debug, Deserialize, Clone)]
struct SyncConfig {
repo_path: PathBuf,
token_file: PathBuf,
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -348,7 +359,7 @@ async fn handle_message(
.trim(); .trim();
match cmd { match cmd {
"start" | "help" => { "start" | "help" => {
let help = "Send any text to save it. Use /add <text> to choose reading list or resources. Use /list to browse. Use /search <query> to find items. Use /undos to manage undo. Use --- to split a message into multiple items."; let help = "Send any text to save it. Use /add <text> to choose reading list or resources. Use /list to browse. Use /search <query> to find items. Use /undos to manage undo. Use /sync to push changes. Use --- to split a message into multiple items.";
bot.send_message(msg.chat.id, help).await?; bot.send_message(msg.chat.id, help).await?;
return Ok(()); return Ok(());
} }
@ -384,6 +395,11 @@ async fn handle_message(
let _ = bot.delete_message(msg.chat.id, msg.id).await; let _ = bot.delete_message(msg.chat.id, msg.id).await;
return Ok(()); return Ok(());
} }
"sync" => {
handle_sync_command(bot.clone(), msg.clone(), state).await?;
let _ = bot.delete_message(msg.chat.id, msg.id).await;
return Ok(());
}
_ => { _ => {
// Unknown command, fall through as text. // Unknown command, fall through as text.
} }
@ -681,6 +697,41 @@ async fn handle_search_command(
Ok(()) Ok(())
} }
async fn handle_sync_command(
bot: Bot,
msg: Message,
state: std::sync::Arc<AppState>,
) -> Result<()> {
let Some(sync) = state.config.sync.clone() else {
send_error(
&bot,
msg.chat.id,
"Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.",
)
.await?;
return Ok(());
};
let chat_id = msg.chat.id;
let outcome = tokio::task::spawn_blocking(move || run_sync(&sync))
.await
.context("sync task failed")?;
match outcome {
Ok(SyncOutcome::NoChanges) => {
send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?;
}
Ok(SyncOutcome::Pushed) => {
send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?;
}
Err(err) => {
send_error(&bot, chat_id, &err.to_string()).await?;
}
}
Ok(())
}
async fn handle_undos_command( async fn handle_undos_command(
bot: Bot, bot: Bot,
msg: Message, msg: Message,
@ -1809,12 +1860,230 @@ enum UserOpOutcome {
Queued, Queued,
} }
enum SyncOutcome {
NoChanges,
Pushed,
}
async fn queue_op(state: &std::sync::Arc<AppState>, op: QueuedOp) -> Result<()> { async fn queue_op(state: &std::sync::Arc<AppState>, op: QueuedOp) -> Result<()> {
let mut queue = state.queue.lock().await; let mut queue = state.queue.lock().await;
queue.push(op); queue.push(op);
save_queue(&state.queue_path, &queue) save_queue(&state.queue_path, &queue)
} }
fn run_sync(sync: &SyncConfig) -> Result<SyncOutcome> {
if !sync.repo_path.exists() {
return Err(anyhow!(
"Sync repo path not found: {}",
sync.repo_path.display()
));
}
let repo_check = run_git(
&sync.repo_path,
&["rev-parse", "--is-inside-work-tree"],
Vec::new(),
)?;
if !repo_check.status.success() || repo_check.stdout.trim() != "true" {
return Err(anyhow!(
"Sync repo path not found or not a git repository: {}",
sync.repo_path.display()
));
}
let token = read_token_file(&sync.token_file)?;
let remotes = git_remote_names(&sync.repo_path)?;
let remote = if remotes.iter().any(|name| name == "origin") {
"origin".to_string()
} else {
remotes
.first()
.cloned()
.ok_or_else(|| anyhow!("Git remote not configured."))?
};
let remote_url = git_remote_url(&sync.repo_path, &remote)?;
if !remote_url.starts_with("https://") {
return Err(anyhow!(
"Sync requires HTTPS remote for PAT auth. Remote is {}",
remote_url
));
}
let username = extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string());
let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?;
if !status_output.status.success() {
return Err(anyhow!(format_git_error("git status", &status_output)));
}
if status_output.stdout.trim().is_empty() {
return Ok(SyncOutcome::NoChanges);
}
let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?;
if !add_output.status.success() {
return Err(anyhow!(format_git_error("git add", &add_output)));
}
let commit_message = sync_commit_message();
let commit_output = run_git(
&sync.repo_path,
&["commit", "-m", &commit_message],
Vec::new(),
)?;
if !commit_output.status.success() {
if is_nothing_to_commit(&commit_output) {
return Ok(SyncOutcome::NoChanges);
}
return Err(anyhow!(format_git_error("git commit", &commit_output)));
}
let branch = git_current_branch(&sync.repo_path)?;
if branch == "HEAD" {
return Err(anyhow!("Sync failed: detached HEAD."));
}
let askpass = create_askpass_script()?;
let askpass_path = askpass.path().to_string_lossy().to_string();
let push_env = vec![
("GIT_TERMINAL_PROMPT", "0".to_string()),
("GIT_ASKPASS", askpass_path),
("GIT_SYNC_USERNAME", username),
("GIT_SYNC_PAT", token),
];
let push_output = run_git(
&sync.repo_path,
&["push", &remote, &format!("HEAD:refs/heads/{}", branch)],
push_env,
)?;
if !push_output.status.success() {
return Err(anyhow!(format_git_error("git push", &push_output)));
}
Ok(SyncOutcome::Pushed)
}
struct GitOutput {
status: std::process::ExitStatus,
stdout: String,
stderr: String,
}
fn run_git(repo_path: &Path, args: &[&str], envs: Vec<(&str, String)>) -> Result<GitOutput> {
let mut cmd = Command::new("git");
cmd.current_dir(repo_path).args(args);
for (key, value) in envs {
cmd.env(key, value);
}
let output = cmd.output().context("run git command")?;
Ok(GitOutput {
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
fn format_git_error(action: &str, output: &GitOutput) -> String {
let mut message = format!("{} failed.", action);
let stdout = output.stdout.trim();
let stderr = output.stderr.trim();
if !stdout.is_empty() {
message.push_str("\nstdout:\n");
message.push_str(stdout);
}
if !stderr.is_empty() {
message.push_str("\nstderr:\n");
message.push_str(stderr);
}
message
}
fn git_remote_names(repo_path: &Path) -> Result<Vec<String>> {
let output = run_git(repo_path, &["remote"], Vec::new())?;
if !output.status.success() {
return Err(anyhow!(format_git_error("git remote", &output)));
}
let names = output
.stdout
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
Ok(names)
}
fn git_remote_url(repo_path: &Path, remote: &str) -> Result<String> {
let output = run_git(repo_path, &["remote", "get-url", remote], Vec::new())?;
if !output.status.success() {
return Err(anyhow!(format_git_error("git remote get-url", &output)));
}
Ok(output.stdout.trim().to_string())
}
fn git_current_branch(repo_path: &Path) -> Result<String> {
let output = run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"], Vec::new())?;
if !output.status.success() {
return Err(anyhow!(format_git_error("git rev-parse", &output)));
}
Ok(output.stdout.trim().to_string())
}
fn read_token_file(path: &Path) -> Result<String> {
let token = match fs::read_to_string(path) {
Ok(token) => token,
Err(_) => {
return Err(anyhow!("Sync requires PAT in settings.sync.token_file."));
}
};
let token = token.trim().to_string();
if token.is_empty() {
return Err(anyhow!("Sync requires PAT in settings.sync.token_file."));
}
Ok(token)
}
fn extract_https_username(remote_url: &str) -> Option<String> {
if !remote_url.starts_with("https://") {
return None;
}
let without_scheme = &remote_url["https://".len()..];
let slash_pos = without_scheme.find('/').unwrap_or(without_scheme.len());
let authority = &without_scheme[..slash_pos];
let userinfo = authority.split('@').next()?;
if !authority.contains('@') {
return None;
}
let username = userinfo.split(':').next().unwrap_or("");
if username.is_empty() {
None
} else {
Some(username.to_string())
}
}
fn is_nothing_to_commit(output: &GitOutput) -> bool {
let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase();
combined.contains("nothing to commit")
|| combined.contains("no changes added to commit")
|| combined.contains("working tree clean")
}
fn sync_commit_message() -> String {
format!("Bot sync {}", Local::now().format("%Y-%m-%d %H:%M:%S"))
}
fn create_askpass_script() -> Result<NamedTempFile> {
let mut file = NamedTempFile::new().context("create askpass script")?;
file.write_all(
b"#!/bin/sh\ncase \"$1\" in\n*Username*) echo \"$GIT_SYNC_USERNAME\" ;;\n*Password*) echo \"$GIT_SYNC_PAT\" ;;\n*) echo \"\" ;;\nesac\n",
)
.context("write askpass script")?;
let mut perms = file.as_file().metadata()?.permissions();
perms.set_mode(0o700);
fs::set_permissions(file.path(), perms).context("chmod askpass script")?;
Ok(file)
}
fn split_items(text: &str) -> Vec<String> { fn split_items(text: &str) -> Vec<String> {
text.split("---") text.split("---")
.map(|s| s.trim()) .map(|s| s.trim())
@ -2957,4 +3226,26 @@ mod tests {
assert!(is_instant_delete_message("Delete")); assert!(is_instant_delete_message("Delete"));
assert!(!is_instant_delete_message("remove")); assert!(!is_instant_delete_message("remove"));
} }
#[test]
fn extract_https_username_from_remote() {
assert_eq!(
extract_https_username("https://user@host/repo.git"),
Some("user".to_string())
);
assert_eq!(
extract_https_username("https://user:pass@host/repo.git"),
Some("user".to_string())
);
assert_eq!(extract_https_username("https://host/repo.git"), None);
assert_eq!(extract_https_username("git@host:repo.git"), None);
}
#[test]
fn read_token_file_trims_whitespace() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b" token\n").unwrap();
let token = read_token_file(file.path()).unwrap();
assert_eq!(token, "token");
}
} }