Add /sync git push command
This commit is contained in:
parent
b45b199ee5
commit
37523a874b
3 changed files with 382 additions and 1 deletions
89
Cargo.lock
generated
89
Cargo.lock
generated
|
|
@ -11,6 +11,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
|
|
@ -138,7 +147,9 @@ version = "0.4.43"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -589,6 +600,30 @@ dependencies = [
|
|||
"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]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
|
|
@ -1015,6 +1050,7 @@ name = "readlater-bot"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"log",
|
||||
|
|
@ -1833,12 +1869,65 @@ version = "0.25.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
env_logger = "0.11"
|
||||
log = "0.4"
|
||||
|
|
|
|||
293
src/main.rs
293
src/main.rs
|
|
@ -1,10 +1,13 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Local;
|
||||
use clap::Parser;
|
||||
use log::error;
|
||||
use rand::seq::SliceRandom;
|
||||
|
|
@ -12,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
|||
use teloxide::prelude::*;
|
||||
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId};
|
||||
use tokio::sync::Mutex;
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ACK_TTL_SECS: u64 = 5;
|
||||
|
|
@ -29,6 +33,13 @@ struct Config {
|
|||
resources_path: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
retry_interval_seconds: Option<u64>,
|
||||
sync: Option<SyncConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct SyncConfig {
|
||||
repo_path: PathBuf,
|
||||
token_file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
|
|
@ -348,7 +359,7 @@ async fn handle_message(
|
|||
.trim();
|
||||
match cmd {
|
||||
"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?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -384,6 +395,11 @@ async fn handle_message(
|
|||
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
||||
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.
|
||||
}
|
||||
|
|
@ -681,6 +697,41 @@ async fn handle_search_command(
|
|||
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(
|
||||
bot: Bot,
|
||||
msg: Message,
|
||||
|
|
@ -1809,12 +1860,230 @@ enum UserOpOutcome {
|
|||
Queued,
|
||||
}
|
||||
|
||||
enum SyncOutcome {
|
||||
NoChanges,
|
||||
Pushed,
|
||||
}
|
||||
|
||||
async fn queue_op(state: &std::sync::Arc<AppState>, op: QueuedOp) -> Result<()> {
|
||||
let mut queue = state.queue.lock().await;
|
||||
queue.push(op);
|
||||
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> {
|
||||
text.split("---")
|
||||
.map(|s| s.trim())
|
||||
|
|
@ -2957,4 +3226,26 @@ mod tests {
|
|||
assert!(is_instant_delete_message("Delete"));
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue