ghtui 0.2.6

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
//! Commits made from objects alone, through an index of their own, to
//! push by their IDs: a clone's branches, index and working tree stay as
//! they are. A wiki's pages ([`crate::wiki`]), the files housekeeping
//! commits ([`crate::housekeeping`]) and a review's suggestions
//! (`crate::suggest`) are saved this way; a merge resolving conflicts
//! ([`crate::git::merge`]) shares its index and its author.

use crate::git::GitError;
use crate::git::repo::Repo;

/// What a commit changes, and says.
pub struct Change<'a> {
    /// Paths and their new text.
    pub additions: &'a [(String, String)],
    pub deletions: &'a [String],
    pub message: &'a str,
    /// Your GitHub login: the commit's author when git has no name for
    /// you.
    pub login: Option<&'a str>,
}

/// The blob at `rev:path`, if there's one.
pub async fn blob(repo: &Repo, rev: &str, path: &str) -> Option<String> {
    let spec = format!("{rev}:{path}");
    let args = ["rev-parse", "--verify", "--quiet", &spec];
    let out = repo.run(&args).await;
    out.ok().map(|o| o.trim().to_owned())
}

/// Whether GitHub refused a push because the branch moved on since it
/// was read: worth reading it again and committing once more.
pub fn moved_on(err: &GitError) -> bool {
    matches!(err, GitError::Failed { stderr, .. }
        if stderr.contains("[rejected]") || stderr.contains("fetch first"))
}

/// The mode of `path` at `rev` (an executable stays one), else a plain
/// file's.
pub(crate) async fn mode(repo: &Repo, rev: &str, path: &str) -> String {
    let out = repo.run(&["ls-tree", rev, "--", path]).await;
    out.ok()
        .and_then(|o| o.split_whitespace().next().map(str::to_owned))
        .filter(|m| m == "100755")
        .unwrap_or_else(|| "100644".to_owned())
}

/// The commit of `change` on `parent`, by you as git knows you (else as
/// your login's GitHub address).
pub async fn make(repo: &Repo, parent: &str, change: &Change<'_>) -> Result<String, GitError> {
    let index = own_index(repo, "edit").await?;
    let index_path = index.to_string_lossy().into_owned();
    let env = [("GIT_INDEX_FILE", index_path.as_str())];
    let tree = async {
        repo.git(&["read-tree", parent], &env, None).await?;
        for path in change.deletions {
            let args = ["update-index", "--force-remove", "--", path];
            repo.git(&args, &env, None).await?;
        }
        for (path, text) in change.additions {
            // A renamed file keeps its mode.
            let was = change
                .deletions
                .first()
                .map_or(path.as_str(), String::as_str);
            let mode = mode(repo, parent, was).await;
            let blob = repo
                .git(&["hash-object", "-w", "--stdin"], &[], Some(text.clone()))
                .await?;
            let info = format!("{mode},{},{path}", blob.trim());
            repo.git(&["update-index", "--add", "--cacheinfo", &info], &env, None)
                .await?;
        }
        // A partial clone lacks the blobs it didn't need.
        repo.git(&["write-tree", "--missing-ok"], &env, None).await
    }
    .await;
    let _ = std::fs::remove_file(&index);
    let tree = tree?;
    commit_tree(repo, tree.trim(), &[parent], change.message, change.login).await
}

/// An index of this process's own in `repo`, `name`d for what it's for,
/// apart from the clone's.
pub(crate) async fn own_index(repo: &Repo, name: &str) -> Result<std::path::PathBuf, GitError> {
    let index = repo
        .run(&["rev-parse", "--git-path", "ghtui-edit-index"])
        .await?;
    let index = repo.path.join(index.trim());
    Ok(index.with_file_name(format!("ghtui-{name}-index-{}", std::process::id())))
}

/// The commit of `tree` on `parents`, saying `message`, by you as git
/// knows you (else as your login's GitHub address).
pub(crate) async fn commit_tree(
    repo: &Repo,
    tree: &str,
    parents: &[&str],
    message: &str,
    login: Option<&str>,
) -> Result<String, GitError> {
    let config = |key: &'static str| crate::git::local::config(&repo.path, key);
    let login = login.unwrap_or("ghtui");
    let name = (config("user.name").await).unwrap_or_else(|| login.to_owned());
    let email =
        (config("user.email").await).unwrap_or_else(|| format!("{login}@users.noreply.github.com"));
    let who = [
        ("GIT_AUTHOR_NAME", name.as_str()),
        ("GIT_AUTHOR_EMAIL", email.as_str()),
        ("GIT_COMMITTER_NAME", name.as_str()),
        ("GIT_COMMITTER_EMAIL", email.as_str()),
    ];
    let mut args = vec!["commit-tree", tree];
    for parent in parents {
        args.extend(["-p", parent]);
    }
    args.extend(["-F", "-"]);
    let made = repo.git(&args, &who, Some(message.to_owned())).await?;
    Ok(made.trim().to_owned())
}