Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ atomic-counter = "1.0.1"
clap = { version = "4.6.0", features = ["derive"] }
dirs = "6.0.0"
git2 = { version = "0.21.0", features = ["ssh"] }
lazy_static = "1.5.0"
rayon = "1.12.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
Expand Down
31 changes: 18 additions & 13 deletions src/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ pub struct LocalChange {

impl LocalChange {
pub fn remote_branch(&self) -> String {
let branch_prefix = env::user_branch_prefix();
let branch_prefix = env::get().user_branch_prefix();
let change_id = &self.id;
format!("{branch_prefix}{change_id}")
}
Expand All @@ -89,13 +89,13 @@ impl LocalChange {
let mut cmd = Command::new("git");
let mut args = vec![
"push".to_string(),
env::remote().into(),
env::get().remote().into(),
"--force".into(),
"--atomic".into(),
];
args.extend(refspecs);
cmd.args(args);
exec!(dry_return = (), cmd);
exec!(env::get(), dry_return = (), cmd);
Ok(())
}
pub fn fetch_all<'a, I: Iterator<Item = &'a Self>>(iterator: I) -> Result<()> {
Expand All @@ -106,15 +106,15 @@ impl LocalChange {
return Ok(());
}
let mut cmd = Command::new("git");
let mut args = vec!["fetch".to_string(), env::remote().into()];
let mut args = vec!["fetch".to_string(), env::get().remote().into()];
args.extend(refspecs);
cmd.args(args);
exec!(dry_return = (), cmd);
exec!(env::get(), dry_return = (), cmd);
Ok(())
}
pub fn diff(&self) -> Result<String> {
let change = self.id.as_str();
let repo = env::repo();
let repo = env::get().repo()?;
let commit = self.commit()?;
let parent = commit
.parent(0)
Expand All @@ -131,8 +131,8 @@ impl LocalChange {
.with_context(|| format!("failed to generate interdiff for change {change}"))?;
Ok(out)
}
pub fn commit<'repo>(&self) -> Result<Commit<'repo>> {
Ok(env::repo().find_commit(self.oid)?)
pub fn commit(&self) -> Result<Commit<'_>> {
Ok(env::get().repo()?.find_commit(self.oid)?)
}
}

Expand Down Expand Up @@ -179,7 +179,7 @@ impl Change {
for pr in merged_prs.iter().rev() {
body.push_str(&format!("- #{}\n", pr.number));
}
body.push_str(&format!("- `{}`\n\n<sub>(Note: Closed and merged PRs may not be reflected here and PR numbering is not stable.)</sub>\n", env::base_branch()));
body.push_str(&format!("- `{}`\n\n<sub>(Note: Closed and merged PRs may not be reflected here and PR numbering is not stable.)</sub>\n", env::get().base_branch()));
let count = changes.len() + merged_prs.len();
let position = count - index;
let prefix = if short_name.is_empty() {
Expand All @@ -193,8 +193,12 @@ impl Change {
/// Adapted from https://joshcannon.me/2025/04/05/pr-interdiff.html
pub fn interdiff(&self) -> Result<String> {
let change = self.local_change.id.as_str();
let repo = env::repo();
let remote_branch = format!("{}/{}", env::remote(), self.local_change.remote_branch());
let repo = env::get().repo()?;
let remote_branch = format!(
"{}/{}",
env::get().remote(),
self.local_change.remote_branch()
);
let old_commit = repo
.revparse_single(remote_branch.as_ref())
.with_context(|| format!("could not parse revspec for remote branch: {remote_branch}"))?
Expand Down Expand Up @@ -250,11 +254,12 @@ fn tree<'repo>(commit: &Commit<'repo>) -> Result<Tree<'repo>> {
}

pub fn get_local_changes() -> Result<Vec<LocalChange>> {
let repo = env::repo();
let repo = env::get().repo()?;
let mut local_changes = vec![];
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.hide_ref(env::base_branch_ref())?;
let base_branch_ref = env::get().base_branch_ref();
revwalk.hide_ref(&base_branch_ref)?;
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
Expand Down
200 changes: 107 additions & 93 deletions src/env.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
// Copyright © 2026 Advanced Micro Devices, Inc. All rights reserved.
// SPDX-License-Identifier: MIT

// FIXME: Newer rustc has started complaining about some of the types
// used through lazy_static, the plan is to remove most uses of
// lazy_static anyway so just bandaiding it for now.
#![allow(dead_code)]

use crate::cli::Cli;
use crate::util::Extract;
use anyhow::{Context, Result, bail};
use atomic_counter::{AtomicCounter, RelaxedCounter};
use clap::Parser;
use git2::Repository;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, read_to_string};
use std::path::PathBuf;
use std::sync::OnceLock;
use thread_local::ThreadLocal;

pub struct ThreadLocalRepo {
Expand All @@ -37,118 +30,139 @@ impl ThreadLocalRepo {
}

#[derive(Default, Serialize, Deserialize)]
pub struct Config {
struct FileConfig {
remote: String,
base_branch: String,
user_branch_prefix: String,
reviewer_groups: Option<HashMap<String, Vec<String>>>,
}

fn repo_config_path(filename: &str) -> Option<PathBuf> {
REPO.get()
.ok()
.and_then(|r| r.workdir())
.map(|wd| wd.join(filename))
.filter(|p| fs::exists(p).unwrap_or(false))
}
pub struct Env {
cli: Cli,
remote: String,
base_branch: String,
user_branch_prefix: String,
reviewer_groups: Option<HashMap<String, Vec<String>>>,
repo: ThreadLocalRepo,
exec_ids: RelaxedCounter,
}

impl Env {
pub fn new(cli: Cli) -> Result<Self> {
let repo = ThreadLocalRepo::new(".".into());
let file_config = read_config(&repo)?;
let remote = cli.globals.remote.clone().unwrap_or(file_config.remote);
let base_branch = cli
.globals
.base_branch
.clone()
.unwrap_or(file_config.base_branch);
let user_branch_prefix = cli
.globals
.user_branch_prefix
.clone()
.unwrap_or(file_config.user_branch_prefix);
if remote.is_empty() {
bail!("field `remote` cannot be empty");
}
if base_branch.is_empty() {
bail!("field `base_branch` cannot be empty");
}
if !user_branch_prefix.is_empty() && !user_branch_prefix.ends_with('/') {
bail!("if field `user_branch_prefix` is non-empty it must end with `/`");
}
Ok(Self {
cli,
remote,
base_branch,
user_branch_prefix,
reviewer_groups: file_config.reviewer_groups,
repo,
exec_ids: RelaxedCounter::new(0),
})
}

fn user_config_path(filename: &str) -> Option<PathBuf> {
dirs::config_dir()
.map(|cd| cd.join(filename))
.filter(|p| fs::exists(p).unwrap_or(false))
}
pub fn cli(&self) -> &Cli {
&self.cli
}

fn read_config() -> Result<Config> {
let path = std::env::var_os("PRADDLE_CONFIG_PATH")
.map(PathBuf::from)
.or_else(|| repo_config_path(".praddle.toml"))
.or_else(|| repo_config_path("praddle.toml"))
.or_else(|| user_config_path("praddle.toml"));
let path = match path {
Some(p) => p,
None => {
// We will catch this later when the config is validated, but we cannot
// fail here or -h/--help will not be reached.
return Ok(Default::default());
}
};
let contents = read_to_string(path.clone())
.with_context(|| format!("could not read config file: {path:?}"))?;
let config: Config = toml::from_str(contents.as_ref())
.with_context(|| format!("invalid config file: {path:?}"))?;
Ok(config)
}
pub fn repo(&self) -> Result<&Repository, git2::Error> {
self.repo.get()
}

lazy_static! {
static ref REPO: ThreadLocalRepo = ThreadLocalRepo::new(".".into());
static ref CONFIG: Config = read_config().extract();
static ref CLI: Cli = Cli::parse();
static ref BASE_BRANCH_REF: String = format!("refs/heads/{}", base_branch());
static ref EXEC_IDS: RelaxedCounter = RelaxedCounter::new(0);
}
pub fn dry_run(&self) -> bool {
self.cli.globals.dry_run
}

pub fn validate() -> Result<()> {
if remote().is_empty() {
bail!("field `remote` cannot be empty");
pub fn verbose(&self) -> bool {
self.cli.globals.verbose
}
if base_branch().is_empty() {
bail!("field `base_branch` cannot be empty");

pub fn always_echo(&self) -> bool {
self.dry_run() || self.verbose()
}
if !user_branch_prefix().is_empty() && !user_branch_prefix().ends_with('/') {
bail!("if field `user_branch_prefix` is non-empty it must end with `/`");

pub fn next_exec_id(&self) -> usize {
self.exec_ids.inc()
}
Ok(())
}

pub fn cli() -> &'static Cli {
&CLI
}
pub fn remote(&self) -> &str {
&self.remote
}

pub fn remote() -> &'static str {
CLI.globals
.remote
.as_deref()
.unwrap_or(CONFIG.remote.as_str())
}
pub fn base_branch(&self) -> &str {
&self.base_branch
}

pub fn base_branch() -> &'static str {
CLI.globals
.base_branch
.as_deref()
.unwrap_or(CONFIG.base_branch.as_str())
}
pub fn base_branch_ref(&self) -> String {
format!("refs/heads/{}", self.base_branch)
}

pub fn base_branch_ref() -> &'static str {
BASE_BRANCH_REF.as_ref()
}
pub fn user_branch_prefix(&self) -> &str {
&self.user_branch_prefix
}

pub fn user_branch_prefix() -> &'static str {
CLI.globals
.user_branch_prefix
.as_deref()
.unwrap_or(CONFIG.user_branch_prefix.as_str())
pub fn reviewer_groups(&self) -> Option<&HashMap<String, Vec<String>>> {
self.reviewer_groups.as_ref()
}
}

pub fn reviewer_groups() -> Option<&'static HashMap<String, Vec<String>>> {
CONFIG.reviewer_groups.as_ref()
}
static ENV: OnceLock<Env> = OnceLock::new();

pub fn dry_run() -> bool {
CLI.globals.dry_run
pub fn init(cli: Cli) -> Result<()> {
ENV.set(Env::new(cli)?)
.map_err(|_| anyhow::anyhow!("environment was initialized more than once"))
}

pub fn verbose() -> bool {
CLI.globals.verbose
pub fn get() -> &'static Env {
ENV.get().expect("environment must be initialized")
}

pub fn always_echo() -> bool {
dry_run() || verbose()
fn repo_config_path(repo: &ThreadLocalRepo, filename: &str) -> Option<PathBuf> {
repo.get()
.ok()
.and_then(|r| r.workdir())
.map(|wd| wd.join(filename))
.filter(|p| fs::exists(p).unwrap_or(false))
}

pub fn repo() -> &'static Repository {
REPO.get().context("not in a git repo").extract()
fn user_config_path(filename: &str) -> Option<PathBuf> {
dirs::config_dir()
.map(|cd| cd.join(filename))
.filter(|p| fs::exists(p).unwrap_or(false))
}

pub fn next_exec_id() -> usize {
EXEC_IDS.inc()
fn read_config(repo: &ThreadLocalRepo) -> Result<FileConfig> {
let path = std::env::var_os("PRADDLE_CONFIG_PATH")
.map(PathBuf::from)
.or_else(|| repo_config_path(repo, ".praddle.toml"))
.or_else(|| repo_config_path(repo, "praddle.toml"))
.or_else(|| user_config_path("praddle.toml"));
let path = match path {
Some(p) => p,
None => return Ok(Default::default()),
};
let contents = read_to_string(path.clone())
.with_context(|| format!("could not read config file: {path:?}"))?;
toml::from_str(contents.as_ref()).with_context(|| format!("invalid config file: {path:?}"))
}
Loading
Loading