thoughts-tool 0.12.0

Flexible thought management using filesystem mounts for git repositories
Documentation
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use std::io::BufRead;
use std::io::BufReader;
use std::path::Path;
use std::process::Command;
use std::process::Stdio;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PushFailureKind {
    Race,
    Auth,
    Network,
    Other,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushResult {
    pub success: bool,
    pub failure_kind: Option<PushFailureKind>,
    pub stderr: String,
}

pub fn build_push_command(repo_path: &Path, remote: &str, branch: &str) -> Command {
    let mut cmd = Command::new("git");
    cmd.current_dir(repo_path)
        .arg("push")
        .arg("--progress")
        .arg(remote)
        .arg(format!("HEAD:refs/heads/{branch}"));
    cmd
}

fn print_progress_line(line: &str) {
    if line.starts_with("To ")
        || line.starts_with("Everything up-to-date")
        || line.contains('%')
        || line.starts_with("remote:")
        || line.contains("Counting objects")
    {
        println!("    {line}");
    }
}

fn sanitize_push_output(text: &str) -> String {
    let mut sanitized = text.to_string();
    for scheme in ["https://", "http://"] {
        sanitized = sanitize_urls_with_scheme(&sanitized, scheme);
    }
    sanitized
}

fn sanitize_urls_with_scheme(text: &str, scheme: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut remaining = text;

    while let Some(idx) = remaining.find(scheme) {
        out.push_str(&remaining[..idx]);

        let after_scheme = &remaining[idx + scheme.len()..];
        let end = after_scheme
            .find(|c: char| {
                c.is_whitespace()
                    || matches!(
                        c,
                        '\'' | '"' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}'
                    )
            })
            .unwrap_or(after_scheme.len());
        let url_body = &after_scheme[..end];
        let tail = &after_scheme[end..];

        out.push_str(scheme);
        out.push_str(&sanitize_url_body(url_body));
        remaining = tail;
    }

    out.push_str(remaining);
    out
}

fn sanitize_url_body(url_body: &str) -> String {
    let (authority, path) = match url_body.find('/') {
        Some(idx) => (&url_body[..idx], &url_body[idx..]),
        None => (url_body, ""),
    };

    if let Some((_, host)) = authority.rsplit_once('@') {
        format!("***@{host}{path}")
    } else {
        url_body.to_string()
    }
}

fn classify_push_failure(stderr: &str) -> PushFailureKind {
    let stderr = stderr.to_ascii_lowercase();

    if stderr.contains("[rejected]")
        || stderr.contains("non-fast-forward")
        || stderr.contains("fetch first")
        || stderr.contains("failed to push some refs")
    {
        return PushFailureKind::Race;
    }

    if stderr.contains("authentication failed")
        || stderr.contains("permission denied")
        || stderr.contains("could not read from remote repository")
        || stderr.contains("repository not found")
    {
        return PushFailureKind::Auth;
    }

    if stderr.contains("could not resolve host")
        || stderr.contains("temporary failure in name resolution")
        || stderr.contains("connection timed out")
        || stderr.contains("operation timed out")
        || stderr.contains("network is unreachable")
        || stderr.contains("no route to host")
        || stderr.contains("connection refused")
        || stderr.contains("connection reset")
    {
        return PushFailureKind::Network;
    }

    PushFailureKind::Other
}

pub fn push_current_branch_with_result(
    repo_path: &Path,
    remote: &str,
    branch: &str,
) -> Result<PushResult> {
    which::which("git").context("git executable not found in PATH")?;

    let mut cmd = build_push_command(repo_path, remote, branch);
    let mut child = cmd
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .context("Failed to spawn git push")?;

    let mut stderr_lines = Vec::new();
    if let Some(stderr) = child.stderr.take() {
        let reader = BufReader::new(stderr);
        for line in reader.lines() {
            match line {
                Ok(line) => {
                    let line = sanitize_push_output(&line);
                    print_progress_line(&line);
                    stderr_lines.push(line);
                }
                Err(_) => break,
            }
        }
    }

    let status = child.wait().context("Failed to wait for git push")?;
    let stderr = stderr_lines.join("\n");

    Ok(PushResult {
        success: status.success(),
        failure_kind: (!status.success()).then(|| classify_push_failure(&stderr)),
        stderr,
    })
}

pub fn push_current_branch(repo_path: &Path, remote: &str, branch: &str) -> Result<()> {
    let result = push_current_branch_with_result(repo_path, remote, branch)?;
    if !result.success {
        let kind = result.failure_kind.unwrap_or(PushFailureKind::Other);
        let stderr = result.stderr.trim();
        if stderr.is_empty() {
            bail!("git push failed ({kind:?})");
        }
        bail!("git push failed ({kind:?}): {stderr}");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_push_cmd_has_expected_args() {
        let cmd = build_push_command(Path::new("/tmp/repo"), "origin", "main");
        let args: Vec<String> = cmd
            .get_args()
            .map(|s| s.to_string_lossy().into_owned())
            .collect();
        assert_eq!(
            args,
            vec!["push", "--progress", "origin", "HEAD:refs/heads/main"]
        );
        assert_eq!(cmd.get_current_dir(), Some(Path::new("/tmp/repo")));
    }

    #[test]
    fn classify_rejected_push_as_race() {
        let stderr =
            "! [rejected]        HEAD -> main (fetch first)\nerror: failed to push some refs";
        assert_eq!(classify_push_failure(stderr), PushFailureKind::Race);
    }

    #[test]
    fn classify_auth_push_failure() {
        let stderr = "remote: Permission denied\nfatal: Could not read from remote repository.";
        assert_eq!(classify_push_failure(stderr), PushFailureKind::Auth);
    }

    #[test]
    fn classify_network_push_failure() {
        let stderr = "fatal: unable to access 'https://example.com/repo.git/': Could not resolve host: example.com";
        assert_eq!(classify_push_failure(stderr), PushFailureKind::Network);
    }

    #[test]
    fn classify_unknown_push_failure_as_other() {
        let stderr = "fatal: unexpected server failure";
        assert_eq!(classify_push_failure(stderr), PushFailureKind::Other);
    }

    #[test]
    fn sanitize_push_output_redacts_https_credentials() {
        let stderr =
            "fatal: unable to access 'https://user:secret@example.com/repo.git/': auth failed";

        let sanitized = sanitize_push_output(stderr);

        assert!(sanitized.contains("https://***@example.com/repo.git/"));
        assert!(!sanitized.contains("user:secret"));
    }

    #[test]
    fn sanitize_push_output_keeps_plain_https_urls() {
        let stderr = "fatal: unable to access 'https://example.com/repo.git/': auth failed";

        assert_eq!(sanitize_push_output(stderr), stderr);
    }
}