From a68207b049cba26f70b3f86dd72317257ae152f5 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:38:27 +0100 Subject: [PATCH] Add pull/push commands and sync flow --- src/main.rs | 283 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 267 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 09f1e99..905133c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -359,7 +359,7 @@ async fn handle_message( .trim(); match cmd { "start" | "help" => { - let help = "Send any text to save it. Use /add to choose reading list or resources. Use /list to browse. Use /search to find items. Use /undos to manage undo. Use /sync to push changes. Use --- to split a message into multiple items."; + let help = "Send any text to save it. Use /add to choose reading list or resources. Use /list to browse. Use /search to find items. Use /undos to manage undo. Use /pull, /pull theirs, /push, /sync. Use --- to split a message into multiple items."; bot.send_message(msg.chat.id, help).await?; return Ok(()); } @@ -395,6 +395,16 @@ async fn handle_message( let _ = bot.delete_message(msg.chat.id, msg.id).await; return Ok(()); } + "pull" => { + handle_pull_command(bot.clone(), msg.clone(), state, rest).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "push" => { + handle_push_command(bot.clone(), msg.clone(), state).await?; + 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; @@ -697,6 +707,85 @@ async fn handle_search_command( Ok(()) } +async fn handle_push_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, +) -> 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_push(&sync)) + .await + .context("push task failed")?; + + match outcome { + Ok(PushOutcome::NoChanges) => { + send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?; + } + Ok(PushOutcome::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_pull_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, + rest: &str, +) -> 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 mode = match parse_pull_mode(rest) { + Ok(mode) => mode, + Err(message) => { + send_error(&bot, msg.chat.id, &message).await?; + return Ok(()); + } + }; + + let chat_id = msg.chat.id; + let outcome = tokio::task::spawn_blocking(move || run_pull(&sync, mode)) + .await + .context("pull task failed")?; + + match outcome { + Ok(PullOutcome::UpToDate) => { + send_ephemeral(&bot, chat_id, "Already up to date.", ACK_TTL_SECS).await?; + } + Ok(PullOutcome::Pulled) => { + send_ephemeral(&bot, chat_id, "Pulled.", ACK_TTL_SECS).await?; + } + Err(err) => { + send_error(&bot, chat_id, &err.to_string()).await?; + } + } + + Ok(()) +} + async fn handle_sync_command( bot: Bot, msg: Message, @@ -713,20 +802,39 @@ async fn handle_sync_command( }; let chat_id = msg.chat.id; - let outcome = tokio::task::spawn_blocking(move || run_sync(&sync)) - .await - .context("sync task failed")?; + let pull_result = tokio::task::spawn_blocking({ + let sync = sync.clone(); + move || run_pull(&sync, PullMode::FastForward) + }) + .await + .context("pull 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?; - } + let pull_outcome = match pull_result { + Ok(outcome) => outcome, Err(err) => { send_error(&bot, chat_id, &err.to_string()).await?; + return Ok(()); } + }; + + let push_result = tokio::task::spawn_blocking(move || run_push(&sync)) + .await + .context("push task failed")?; + + let push_outcome = match push_result { + Ok(outcome) => outcome, + Err(err) => { + send_error(&bot, chat_id, &err.to_string()).await?; + return Ok(()); + } + }; + + let did_work = matches!(pull_outcome, PullOutcome::Pulled) + || matches!(push_outcome, PushOutcome::Pushed); + if did_work { + send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?; + } else { + send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?; } Ok(()) @@ -1860,18 +1968,28 @@ enum UserOpOutcome { Queued, } -enum SyncOutcome { +enum PushOutcome { NoChanges, Pushed, } +enum PullOutcome { + UpToDate, + Pulled, +} + +enum PullMode { + FastForward, + Theirs, +} + async fn queue_op(state: &std::sync::Arc, 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 { +fn run_push(sync: &SyncConfig) -> Result { ensure_git_available()?; if !sync.repo_path.exists() { return Err(anyhow!( @@ -1918,7 +2036,7 @@ fn run_sync(sync: &SyncConfig) -> Result { return Err(anyhow!(format_git_error("git status", &status_output))); } if status_output.stdout.trim().is_empty() { - return Ok(SyncOutcome::NoChanges); + return Ok(PushOutcome::NoChanges); } let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?; @@ -1934,7 +2052,7 @@ fn run_sync(sync: &SyncConfig) -> Result { )?; if !commit_output.status.success() { if is_nothing_to_commit(&commit_output) { - return Ok(SyncOutcome::NoChanges); + return Ok(PushOutcome::NoChanges); } return Err(anyhow!(format_git_error("git commit", &commit_output))); } @@ -1961,7 +2079,103 @@ fn run_sync(sync: &SyncConfig) -> Result { return Err(anyhow!(format_git_error("git push", &push_output))); } - Ok(SyncOutcome::Pushed) + Ok(PushOutcome::Pushed) +} + +fn run_pull(sync: &SyncConfig, mode: PullMode) -> Result { + ensure_git_available()?; + 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 Err(anyhow!( + "Working tree has uncommitted changes; commit or stash before pull." + )); + } + + 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.to_string_lossy().to_string(); + let pull_env = vec![ + ("GIT_TERMINAL_PROMPT", "0".to_string()), + ("GIT_ASKPASS", askpass_path), + ("GIT_SYNC_USERNAME", username), + ("GIT_SYNC_PAT", token), + ]; + + let pull_args: Vec = match mode { + PullMode::FastForward => vec![ + "pull".to_string(), + "--ff-only".to_string(), + remote, + branch, + ], + PullMode::Theirs => vec![ + "pull".to_string(), + "--no-edit".to_string(), + "-X".to_string(), + "theirs".to_string(), + remote, + branch, + ], + }; + let pull_args_ref: Vec<&str> = pull_args.iter().map(|arg| arg.as_str()).collect(); + let pull_output = run_git(&sync.repo_path, &pull_args_ref, pull_env)?; + if !pull_output.status.success() { + return Err(anyhow!(format_git_error("git pull", &pull_output))); + } + + if is_already_up_to_date(&pull_output) { + Ok(PullOutcome::UpToDate) + } else { + Ok(PullOutcome::Pulled) + } } struct GitOutput { @@ -2086,6 +2300,22 @@ fn is_nothing_to_commit(output: &GitOutput) -> bool { || combined.contains("working tree clean") } +fn is_already_up_to_date(output: &GitOutput) -> bool { + let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase(); + combined.contains("already up to date") || combined.contains("already up-to-date") +} + +fn parse_pull_mode(rest: &str) -> std::result::Result { + let option = rest.trim(); + if option.is_empty() { + return Ok(PullMode::FastForward); + } + if option.eq_ignore_ascii_case("theirs") { + return Ok(PullMode::Theirs); + } + Err("Unknown pull option. Use /pull or /pull theirs.".to_string()) +} + fn sync_commit_message() -> String { format!("Bot sync {}", Local::now().format("%Y-%m-%d %H:%M:%S")) } @@ -3083,6 +3313,7 @@ async fn process_queue(state: std::sync::Arc) -> Result<()> { mod tests { use super::*; use std::collections::HashSet; + use std::os::unix::process::ExitStatusExt; fn entry(text: &str) -> EntryBlock { EntryBlock::from_text(text) @@ -3266,4 +3497,24 @@ mod tests { let token = read_token_file(file.path()).unwrap(); assert_eq!(token, "token"); } + + #[test] + fn parse_pull_mode_accepts_theirs() { + assert!(matches!(parse_pull_mode(""), Ok(PullMode::FastForward))); + assert!(matches!( + parse_pull_mode("theirs"), + Ok(PullMode::Theirs) + )); + assert!(parse_pull_mode("unknown").is_err()); + } + + #[test] + fn is_already_up_to_date_detects_output() { + let output = GitOutput { + status: std::process::ExitStatus::from_raw(0), + stdout: "Already up to date.".to_string(), + stderr: String::new(), + }; + assert!(is_already_up_to_date(&output)); + } }