feat: browse GitHub GitLab and Gitea pull requests

This commit is contained in:
Valdemar Lorenzen
2026-10-14 13:22:09 +02:00
parent 15968a328c
commit af6496bd5a
11 changed files with 1349 additions and 47 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- whitespace kind -> character map should be configurable.
- [x] add option to insert spaces instead tab character when pressing tab:
- number of spaces should be configurable (per language)
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
- [x] github/gitlab/gitea integration (`:pull_requests [remote]`)
- github pr picker (floating window)
- three tabs: open, closed, all
- search under the given category
+10
View File
@@ -376,6 +376,16 @@ Printable input belongs to the focused query, including `:`, `_`, and `|`; Norma
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
**Pull requests:** run **`:pull_requests [remote]`**, use the palette's `pull_requests` action, or bind **`config.core.pull_requests`**. The floating picker has **Open / Closed / All** tabs; Closed includes merged requests. Tab/Shift+Tab changes category, typing fuzzy-searches its number, title, author, state, and branches, and Enter immediately opens the selected request in **Branch changes** mode using its immutable head and merge base. Escape restores the source buffer. A failed list can be retried with Enter. The default remote is `origin`, or the sole configured remote; specify a name when there are multiple remotes without `origin`.
GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately.
Standard GitHub/GitLab/Gitea hosts and Codeberg are detected from HTTPS or SSH remote URLs. For another host, set `remote.<remote>.vamp-forge` to `github`, `gitlab`, or `gitea` in the repository's Git configuration, for example `git config remote.origin.vamp-forge gitea`. For a non-colocated jj repository, use `git --git-dir .jj/repo/store/git config remote.origin.vamp-forge gitea`.
The picker loads every API page and searches locally without additional network requests when changing tabs or typing. GitLab also reads each request's historical diff metadata; large histories can take longer, and Escape cancels outstanding work. Fork heads are fetched through the target repository's PR refs, into the existing private comparison store, without changing active refs, jj operations, or checkout files. Closed/merged requests retain historical bases and heads rather than comparing against today's branch tips. If the server no longer retains the required commits, opening the request reports an error instead of displaying a different diff. Review submission and comments are separate, not part of this picker.
Requests with missing/unprepared historical metadata remain listed with an `unavailable` reason. Selecting one reports that error without blocking other requests or substituting a live branch tip.
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following.
+292 -18
View File
@@ -19,6 +19,9 @@ pub enum Category {
Commits,
Branches,
Tags,
Open,
Closed,
All,
}
impl Category {
pub fn label(self) -> &'static str {
@@ -28,6 +31,9 @@ impl Category {
Self::Commits => "Commits",
Self::Branches => "Branches",
Self::Tags => "Tags",
Self::Open => "Open",
Self::Closed => "Closed",
Self::All => "All",
}
}
}
@@ -57,7 +63,23 @@ impl ComparisonMode {
pub enum RevisionTarget {
Working,
Commit(String),
Remote { remote: String, reference: String },
Remote {
remote: String,
reference: String,
},
RemoteCommit {
remote: String,
reference: String,
id: String,
},
PullRequest {
remote: String,
base_ref: String,
base_sha: String,
head_ref: String,
head_sha: String,
},
UnavailablePullRequest(String),
ListRemote(String),
FetchRemote(String),
}
@@ -114,6 +136,7 @@ pub struct Comparison {
#[derive(Debug)]
pub enum Operation {
Discover,
PullRequests(String),
ListRemote(String),
FetchRemote(String),
Compare {
@@ -208,6 +231,7 @@ enum RequestKind {
#[derive(Debug)]
pub struct DiffState {
pub catalog: Option<Catalog>,
pub pull_request_remote: Option<String>,
pub phase: Phase,
pub word_wrap: bool,
pub view_mode: ViewMode,
@@ -256,6 +280,7 @@ impl DiffState {
pub(crate) fn new() -> Self {
Self {
catalog: None,
pull_request_remote: None,
phase: Phase::Before,
word_wrap: false,
view_mode: ViewMode::Split,
@@ -301,6 +326,24 @@ impl DiffState {
}
}
pub(crate) fn pull_requests(remote: String) -> Self {
Self {
pull_request_remote: Some(remote.clone()),
pending: Some(Operation::PullRequests(remote)),
..Self::new()
}
}
pub fn categories(&self) -> &'static [Category] {
if self.pull_request_remote.is_some() {
&[Category::Open, Category::Closed, Category::All]
} else {
self.catalog
.as_ref()
.map_or(&[], |catalog| catalog.kind.categories())
}
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &Revision> {
self.visible
.iter()
@@ -507,8 +550,8 @@ impl DiffState {
]
})
.collect();
self.category = self.category.min(catalog.kind.categories().len() - 1);
self.catalog = Some(catalog);
self.category = self.category.min(self.categories().len() - 1);
self.refilter();
if let Some(target) = selected {
self.select_target(&target);
@@ -588,20 +631,26 @@ impl DiffState {
self.visible.clear();
self.search_matches.clear();
if let Some(catalog) = &self.catalog {
let category = catalog.kind.categories()[self.category];
let category = self.categories()[self.category];
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
// Working is always pinned, even outside its category or query.
self.visible.extend(
catalog
.entries
.iter()
.enumerate()
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
.map(|(index, _)| index),
);
// Repository revisions pin Working outside its category and query.
if self.pull_request_remote.is_none() {
self.visible.extend(
catalog
.entries
.iter()
.enumerate()
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
.map(|(index, _)| index),
);
}
for (index, entry) in catalog.entries.iter().enumerate() {
if entry.target == RevisionTarget::Working || entry.category != category {
if entry.target == RevisionTarget::Working
|| (entry.category != category
&& !(category == Category::All
&& matches!(entry.category, Category::Open | Category::Closed)))
{
continue;
}
if tokens.is_empty() {
@@ -704,8 +753,8 @@ impl DiffState {
}
match input {
Input::Char('\t') | Input::BackTab => {
if let Some(catalog) = &self.catalog {
let count = catalog.kind.categories().len();
let count = self.categories().len();
if count > 0 {
self.category = if input == Input::BackTab {
(self.category + count - 1) % count
} else {
@@ -742,16 +791,53 @@ impl DiffState {
fn choose(&mut self) {
let revision = self.entries().nth(self.selected).cloned();
let Some(revision) = revision else {
// Discovery can be retried without closing the modal.
// Listing can be retried without closing the modal.
if self.catalog.is_none() {
self.queue(Operation::Discover);
self.queue(match &self.pull_request_remote {
Some(remote) => Operation::PullRequests(remote.clone()),
None => Operation::Discover,
});
}
return;
};
if let RevisionTarget::UnavailablePullRequest(error) = &revision.target {
self.before = None;
self.after = None;
self.error = Some(error.clone());
return;
}
if revision.target == RevisionTarget::Working {
self.comparison_mode = ComparisonMode::Snapshots;
}
if let RevisionTarget::FetchRemote(remote) = &revision.target {
if let RevisionTarget::PullRequest {
remote,
base_ref,
base_sha,
head_ref,
head_sha,
} = &revision.target
{
self.before = Some(Revision {
target: RevisionTarget::RemoteCommit {
remote: remote.clone(),
reference: base_ref.clone(),
id: base_sha.clone(),
},
label: base_ref.clone(),
description: String::new(),
category: revision.category,
});
self.after = Some(Revision {
target: RevisionTarget::RemoteCommit {
remote: remote.clone(),
reference: head_ref.clone(),
id: head_sha.clone(),
},
..revision
});
self.comparison_mode = ComparisonMode::BranchChanges;
self.queue_comparison();
} else if let RevisionTarget::FetchRemote(remote) = &revision.target {
self.queue(Operation::FetchRemote(remote.clone()));
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
self.queue(Operation::ListRemote(remote.clone()));
@@ -776,6 +862,9 @@ impl DiffState {
}
pub(crate) fn toggle_comparison_mode(&mut self) -> Result<(), &'static str> {
if self.pull_request_remote.is_some() && self.after.is_none() {
return Err("Select a pull request before changing comparison mode");
}
if self.working_selected() {
return Err("Working checkout requires Snapshots");
}
@@ -2332,6 +2421,191 @@ mod tests {
}
}
fn pull_request_catalog() -> Catalog {
Catalog {
kind: RepoKind::Git,
entries: [
("#1 Fix Ünicode", Category::Open),
("#2 Fix Ünicode", Category::Closed),
("#3 Other work", Category::Open),
]
.into_iter()
.map(|(label, category)| {
revision(
RevisionTarget::PullRequest {
remote: "origin".into(),
base_ref: "refs/heads/main".into(),
base_sha: "base-sha".into(),
head_ref: "refs/pull/1/head".into(),
head_sha: "head-sha".into(),
},
label,
"Alice · main ← topic",
category,
)
})
.collect(),
notice: None,
}
}
#[test]
fn pull_request_categories_keep_query_and_select_both_pinned_branch_tips() {
let mut state = DiffState::pull_requests("origin".into());
assert_eq!(
state.categories(),
[Category::Open, Category::Closed, Category::All]
);
state.take_request();
state.handle(Input::Char('\t'));
for ch in "FIX üni Alice".chars() {
state.handle(Input::Char(ch));
}
state.apply_result(1, Ok(Outcome::Catalog(pull_request_catalog())));
assert_eq!(
state
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["#2 Fix Ünicode"]
);
state.handle(Input::Char('\t'));
assert_eq!(state.entries().len(), 2);
state.handle(Input::Char('\t'));
assert_eq!(state.query, "FIX üni Alice");
assert_eq!(
state
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["#1 Fix Ünicode"]
);
state.handle(Input::Ctrl('u'));
assert_eq!(state.entries().len(), 2);
state.comparison_mode = ComparisonMode::Snapshots;
state.handle(Input::Enter);
assert_eq!(state.phase, Phase::After);
let Operation::Compare {
before,
after,
mode,
buffers,
} = state.take_request().unwrap()
else {
panic!("expected direct paired comparison");
};
assert_eq!(mode, ComparisonMode::BranchChanges);
assert!(buffers.is_empty());
assert_eq!(before.label, "refs/heads/main");
assert_eq!(after.label, "#1 Fix Ünicode");
assert_eq!(
before.target,
RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: "refs/heads/main".into(),
id: "base-sha".into(),
}
);
assert_eq!(
after.target,
RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: "refs/pull/1/head".into(),
id: "head-sha".into(),
}
);
}
#[test]
fn pull_request_loading_retry_and_escape_reject_stale_results() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("unsaved source".into())])
.unwrap();
editor.execute("pull_requests origin");
let (first, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
editor.handle(Input::Ctrl('b'));
editor.handle(Input::Enter);
assert!(editor.diff().unwrap().loading);
assert!(editor.take_diff_request().is_none());
text(&mut editor, "alice");
assert!(editor.apply_diff_result(first, Err("API unavailable".into())));
editor.handle(Input::Enter);
let (retry, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
assert_eq!(editor.diff().unwrap().query, "alice");
assert!(!editor.apply_diff_result(first, Ok(Outcome::Catalog(pull_request_catalog()))));
assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
editor.handle(Input::Enter);
let (compare, operation) = editor.take_diff_request().unwrap();
assert!(matches!(
operation,
Operation::Compare {
mode: ComparisonMode::BranchChanges,
..
}
));
assert!(editor.apply_diff_result(compare, Err("fetch unavailable".into())));
editor.handle(Input::Enter);
let (compare_retry, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Compare { .. }));
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert!(!editor.apply_diff_result(compare_retry, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.lines(), ["unsaved source"]);
assert!(editor.dirty());
editor.execute("pull_requests");
let (reopened, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::PullRequests(remote) if remote.is_empty()));
assert!(!editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
let mut empty = pull_request_catalog();
empty.entries.clear();
assert!(editor.apply_diff_result(reopened, Ok(Outcome::Catalog(empty))));
editor.handle(Input::Enter);
assert!(editor.take_diff_request().is_none());
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
}
#[test]
fn unavailable_pull_request_does_not_block_other_catalog_entries() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
let mut catalog = pull_request_catalog();
catalog.entries.insert(
0,
revision(
RevisionTarget::UnavailablePullRequest("Historical head no longer retained".into()),
"#194 Deleted head",
"unavailable",
Category::Open,
),
);
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
state.handle(Input::Enter);
assert_eq!(
state.error.as_deref(),
Some("Historical head no longer retained")
);
assert!(state.take_request().is_none());
state.handle(Input::Down);
state.handle(Input::Enter);
assert!(matches!(
state.take_request(),
Some(Operation::Compare {
mode: ComparisonMode::BranchChanges,
..
})
));
state.apply_result(2, Err("fetch failed".into()));
state.handle(Input::Home);
state.handle(Input::Enter);
assert!(state.before.is_none() && state.after.is_none());
assert!(state.toggle_comparison_mode().is_err());
assert!(state.take_request().is_none());
}
fn editor() -> Editor {
Editor::open(None, vec![]).unwrap()
}
+18 -5
View File
@@ -793,6 +793,12 @@ core_actions![
true
),
("core.diff", Action::Diff, &[Mode::Normal], true),
(
"core.pull_requests",
Action::PullRequests,
&[Mode::Normal],
true
),
(
"core.diff.mode.toggle",
Action::DiffModeToggle,
@@ -2911,7 +2917,7 @@ impl Editor {
true
}
fn open_diff(&mut self) {
fn open_diff(&mut self, mut diff: DiffState) {
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
self.with_pane(owner, |editor| {
editor.release_diff_buffers();
@@ -2933,7 +2939,6 @@ impl Editor {
self.active.view.mode = Mode::Normal;
}
self.outstanding_diff_request = None;
let mut diff = DiffState::new();
diff.word_wrap = self.active.view.word_wrap;
diff.view_mode = self.diff_display_options.0;
diff.split_axis = self.diff_display_options.1;
@@ -3522,6 +3527,7 @@ impl Editor {
| Action::Palette
| Action::FilePicker
| Action::FileExplorer
| Action::PullRequests
| Action::DiffClose
| Action::DiffModeToggle
| Action::DiffViewToggle
@@ -6595,6 +6601,7 @@ impl Editor {
| Action::BufferDelete
| Action::BufferForceDelete
| Action::Diff
| Action::PullRequests
| Action::DiffClose
| Action::DiffModeToggle
| Action::DiffViewToggle
@@ -7541,9 +7548,15 @@ impl Editor {
self.set_message("Diff pane is read-only");
return None;
}
if entry.action == Action::Diff {
if args.is_empty() {
self.open_diff();
if matches!(entry.action, Action::Diff | Action::PullRequests) {
if entry.action == Action::PullRequests {
if args.split_whitespace().count() > 1 {
self.set_message("Usage: pull_requests [remote]");
} else {
self.open_diff(DiffState::pull_requests(args.into()));
}
} else if args.is_empty() {
self.open_diff(DiffState::new());
} else {
self.set_message("The diff command takes no arguments");
}
+729
View File
@@ -0,0 +1,729 @@
//! Read-only forge catalogs; fetch targets always stay on the configured remote.
use crate::repository::{checked, run};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
process::Command,
sync::atomic::{AtomicBool, Ordering},
};
pub(crate) struct PullRequest {
pub number: u64,
pub title: String,
pub description: String,
pub closed: bool,
pub revisions: Result<PullRequestRevisions, String>,
}
pub(crate) struct PullRequestRevisions {
pub base_ref: String,
pub base_sha: String,
pub head_ref: String,
pub head_sha: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Provider {
Github,
Gitlab,
Gitea,
}
struct Forge {
provider: Provider,
host: String,
endpoint: String,
}
fn encode(text: &str) -> String {
use std::fmt::Write;
let mut encoded = String::new();
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
encoded.push(byte as char);
} else {
write!(encoded, "%{byte:02X}").unwrap();
}
}
encoded
}
fn decode(path: &str) -> Result<String, String> {
let mut bytes = path.bytes();
let mut decoded = Vec::with_capacity(path.len());
while let Some(byte) = bytes.next() {
decoded.push(if byte == b'%' {
let hi = bytes.next().and_then(|b| (b as char).to_digit(16));
let lo = bytes.next().and_then(|b| (b as char).to_digit(16));
let (hi, lo) = hi.zip(lo).ok_or("Invalid escaped remote path")?;
(hi * 16 + lo) as u8
} else {
byte
});
}
String::from_utf8(decoded).map_err(|_| "Remote path is not UTF-8".into())
}
impl Forge {
fn parse(url: &str, provider: Option<&str>) -> Result<Self, String> {
if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains(['?', '#', '\\']) {
return Err("Invalid forge remote URL".into());
}
let (authority, path, ssh) = if let Some((scheme, rest)) = url.split_once("://") {
if !matches!(scheme, "https" | "ssh" | "git+ssh") {
return Err("Pull requests require an HTTPS or SSH remote".into());
}
let (authority, path) = rest
.split_once('/')
.ok_or("Forge remote has no repository path")?;
(authority, path, scheme != "https")
} else {
let separator = if let Some(end) = url.find(']') {
end + 1
} else {
url.find(':')
.ok_or("Pull requests require an HTTPS or SSH remote")?
};
if url.as_bytes().get(separator) != Some(&b':') {
return Err("Invalid SSH remote".into());
}
(&url[..separator], &url[separator + 1..], true)
};
// Never reuse URL userinfo as an API credential or include it in arguments/errors.
let authority = authority.rsplit('@').next().unwrap();
let (host, port) = if authority.starts_with('[') {
let end = authority.find(']').ok_or("Invalid remote host")?;
authority[1..end]
.parse::<std::net::Ipv6Addr>()
.map_err(|_| "Invalid remote host")?;
(&authority[..=end], &authority[end + 1..])
} else {
let (host, port) = authority.split_at(authority.find(':').unwrap_or(authority.len()));
if host.is_empty()
|| host.starts_with('-')
|| !host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b))
{
return Err("Invalid remote host".into());
}
(host, port)
};
if !port.is_empty()
&& port
.strip_prefix(':')
.and_then(|p| p.parse::<u16>().ok())
.filter(|p| *p != 0)
.is_none()
{
return Err("Invalid remote port".into());
}
let mut host = host.to_ascii_lowercase();
if ssh && host == "ssh.github.com" {
host = "github.com".into();
}
if ssh && host == "altssh.gitlab.com" {
host = "gitlab.com".into();
}
let provider = match provider {
Some("github") => Provider::Github,
Some("gitlab") => Provider::Gitlab,
Some("gitea") => Provider::Gitea,
Some(_) => {
return Err("remote.<name>.vamp-forge must be github, gitlab, or gitea".into());
}
None if host == "github.com" => Provider::Github,
None if host.split('.').any(|label| label == "gitlab") => Provider::Gitlab,
None if host == "codeberg.org" || host.split('.').any(|label| label == "gitea") => {
Provider::Gitea
}
None => {
return Err(format!(
"Unknown forge for {host}; run `git config remote.<name>.vamp-forge github` (or use gitlab or gitea)"
));
}
};
let path = decode(path.trim_matches('/'))?;
let path = path.strip_suffix(".git").unwrap_or(&path);
let parts: Vec<_> = path.split('/').collect();
if parts.len() < 2
|| (provider != Provider::Gitlab && parts.len() != 2)
|| parts.iter().any(|p| {
p.is_empty() || matches!(*p, "." | "..") || p.chars().any(char::is_control)
})
{
return Err("Invalid repository path for the configured forge".into());
}
let authority = format!("{host}{}", if ssh { "" } else { port });
let endpoint = match provider {
Provider::Github => format!(
"{}/repos/{}/{}/pulls",
if host == "github.com" && (ssh || port.is_empty() || port == ":443") {
"https://api.github.com".into()
} else {
format!("https://{authority}/api/v3")
},
encode(parts[0]),
encode(parts[1])
),
Provider::Gitlab => format!(
"https://{authority}/api/v4/projects/{}/merge_requests",
encode(path)
),
Provider::Gitea => format!(
"https://{authority}/api/v1/repos/{}/{}/pulls",
encode(parts[0]),
encode(parts[1])
),
};
Ok(Self {
provider,
host: authority,
endpoint,
})
}
fn token(&self, root: &Path, cancelled: &AtomicBool) -> Result<Option<String>, String> {
let names: &[&str] = match self.provider {
Provider::Github if self.host == "github.com" || self.host == "github.com:443" => {
&["GH_TOKEN", "GITHUB_TOKEN"]
}
Provider::Github => &["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"],
Provider::Gitlab => &["GITLAB_TOKEN"],
Provider::Gitea => &["GITEA_TOKEN"],
};
let mut token = None;
for name in names {
match std::env::var(name) {
Ok(value) if !value.is_empty() => {
token = Some(value);
break;
}
Err(std::env::VarError::NotUnicode(_)) => {
return Err("Forge token is not valid UTF-8".into());
}
_ => {}
}
}
if token.is_none() && self.provider != Provider::Gitea {
let mut command = Command::new(if self.provider == Provider::Github {
"gh"
} else {
"glab"
});
let host = if self.provider == Provider::Github && self.host == "github.com:443" {
"github.com"
} else {
&self.host
};
command
.current_dir(root)
.args(["auth", "token", "--hostname", host]);
match run(command, Vec::new(), cancelled) {
Ok(output) => {
if let Ok(bytes) = checked(output) {
token = Some(
String::from_utf8(bytes)
.map_err(|_| "Stored forge token is not valid UTF-8")?
.trim()
.to_owned(),
);
}
}
Err(error)
if cancelled.load(Ordering::Acquire)
|| !error.starts_with("Cannot start repository command:") =>
{
return Err(error);
}
Err(_) => {}
}
}
token
.filter(|t| !t.is_empty())
.map(|token| {
if token.bytes().all(|b| b.is_ascii_graphic()) {
Ok(token)
} else {
Err("Forge token contains invalid header characters".into())
}
})
.transpose()
}
fn get(
&self,
url: &str,
token: Option<&str>,
root: &Path,
cancelled: &AtomicBool,
) -> Result<Value, String> {
let mut headers = String::from("Accept: application/json\n");
if let Some(token) = token {
let prefix = match self.provider {
Provider::Github => "Authorization: Bearer",
Provider::Gitlab => "PRIVATE-TOKEN:",
Provider::Gitea => "Authorization: token",
};
headers.push_str(&format!("{prefix} {token}\n"));
}
// Ignore curlrc; credentials only travel on stdin. Never follow Location or API-provided URLs.
let mut command = Command::new("curl");
command.current_dir(root).args([
"--disable",
"--silent",
"--globoff",
"--proto",
"=https",
"--connect-timeout",
"15",
"--max-time",
"55",
"--max-redirs",
"0",
"--user-agent",
"vamp",
"--header",
"@-",
"--write-out",
"\n%{http_code}",
"--url",
url,
]);
let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err(
|_| "Forge API request failed; check curl, network access, and authentication",
)?;
let split = bytes
.iter()
.rposition(|b| *b == b'\n')
.ok_or("Invalid forge HTTP response")?;
let status = &bytes[split + 1..];
if status != b"200" {
let code = std::str::from_utf8(status)
.ok()
.filter(|s| s.len() == 3 && s.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or("unknown");
return Err(format!(
"Forge API returned HTTP {code}; check remote URL and authentication (redirects are not followed)"
));
}
serde_json::from_slice(&bytes[..split])
.map_err(|_| "Forge API returned invalid JSON".into())
}
}
fn field<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
value
.get(key)
.and_then(Value::as_str)
.ok_or_else(|| format!("Forge API returned invalid {key}"))
}
fn number(value: &Value, key: &str) -> Result<u64, String> {
value
.get(key)
.and_then(Value::as_u64)
.filter(|n| *n > 0)
.ok_or_else(|| format!("Forge API returned invalid {key}"))
}
fn sha(value: &Value, key: &str) -> Result<String, String> {
let sha = field(value, key)?;
if matches!(sha.len(), 40 | 64)
&& sha.bytes().all(|b| b.is_ascii_hexdigit())
&& sha.bytes().any(|b| b != b'0')
{
Ok(sha.to_ascii_lowercase())
} else {
Err(format!("Forge API returned invalid {key} commit ID"))
}
}
fn branch<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
let branch = field(value, key)?;
if branch.is_empty()
|| branch.contains(['~', '^', ':', '?', '*', '[', '\\'])
|| branch.contains("..")
|| branch.contains("@{")
|| branch.bytes().any(|b| b <= b' ' || b == 127)
|| branch
.split('/')
.any(|p| p.is_empty() || p.starts_with('.') || p.ends_with('.') || p.ends_with(".lock"))
{
Err(format!("Forge API returned invalid {key} branch"))
} else {
Ok(branch)
}
}
fn pull_request(
provider: Provider,
value: &Value,
preserved_head: Option<String>,
) -> Result<PullRequest, String> {
let gitlab = provider == Provider::Gitlab;
let number = number(value, if gitlab { "iid" } else { "number" })?;
let state = field(value, "state")?;
let closed = match (gitlab, state) {
(false, "open") | (true, "opened" | "locked") => false,
(false, "closed") | (true, "closed" | "merged") => true,
_ => return Err("Forge API returned invalid pull request state".into()),
};
let merged = match provider {
Provider::Github => match value.get("merged_at") {
Some(Value::Null) => false,
Some(Value::String(date)) if !date.is_empty() => true,
_ => return Err("Forge API returned invalid merged_at".into()),
},
Provider::Gitlab => state == "merged",
Provider::Gitea => value
.get("merged")
.and_then(Value::as_bool)
.ok_or("Forge API returned invalid merged flag")?,
};
if merged && !closed {
return Err("Forge API returned inconsistent merged state".into());
}
let revisions = (|| -> Result<_, String> {
let (base, head, base_sha, head_sha) = if gitlab {
let refs = value.get("diff_refs").filter(|v| v.is_object()).ok_or(
"GitLab merge request has no prepared diff_refs; retry after GitLab prepares it",
)?;
// Legacy MRs omit the target tip; their recorded merge base yields the same PR diff.
(
branch(value, "target_branch")?,
branch(value, "source_branch")?,
sha(
refs,
if refs.get("start_sha").is_none_or(Value::is_null) {
"base_sha"
} else {
"start_sha"
},
)?,
sha(refs, "head_sha")?,
)
} else {
let base = value
.get("base")
.ok_or("Forge API returned no base branch")?;
let head = value
.get("head")
.ok_or("Forge API returned no head branch")?;
// Gitea base.sha follows the live target branch even after merging.
let base_sha = if provider == Provider::Gitea {
sha(value, "merge_base")?
} else {
sha(base, "sha")?
};
let head_key = if provider == Provider::Gitea
&& head
.get("label")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty())
{
"label"
} else {
"ref"
};
(
branch(base, "ref")?,
if provider == Provider::Gitea {
field(head, head_key)?
} else {
branch(head, head_key)?
},
base_sha,
if provider == Provider::Gitea {
preserved_head.ok_or_else(|| {
format!("Gitea pull request #{number} has no preserved head reference")
})?
} else {
sha(head, "sha")?
},
)
};
Ok((
PullRequestRevisions {
base_ref: format!("refs/heads/{base}"),
base_sha,
head_ref: format!(
"refs/{}/{number}/head",
if gitlab { "merge-requests" } else { "pull" }
),
head_sha,
},
base,
head,
))
})();
let author = value
.get(if gitlab { "author" } else { "user" })
.ok_or("Forge API returned no author")?;
let author = if author.is_null() {
"deleted user"
} else {
field(author, if gitlab { "username" } else { "login" })?
};
let state = if merged {
"merged"
} else if closed {
"closed"
} else {
"open"
};
Ok(PullRequest {
number,
title: field(value, "title")?.to_owned(),
description: match &revisions {
Ok((_, base, head)) => format!("{author} · {state} · {head} → {base}"),
Err(error) => format!("{author} · {state} · unavailable: {error}"),
},
closed,
revisions: revisions.map(|(revisions, _, _)| revisions),
})
}
pub(crate) fn list(
url: &str,
provider: Option<&str>,
root: &Path,
cancelled: &AtomicBool,
) -> Result<Vec<PullRequest>, String> {
let forge = Forge::parse(url, provider)?;
let token = forge.token(root, cancelled)?;
let mut gitea_heads = BTreeMap::new();
let query = match forge.provider {
Provider::Github => "state=all&per_page=100&sort=created&direction=asc",
Provider::Gitlab => "state=all&scope=all&per_page=100&order_by=created_at&sort=asc",
Provider::Gitea => "state=all&limit=100&sort=oldest",
};
let mut requests = Vec::new();
let mut seen = BTreeSet::new();
for page in 1u64.. {
let values = forge.get(
&format!("{}?{query}&page={page}", forge.endpoint),
token.as_deref(),
root,
cancelled,
)?;
let values = values
.as_array()
.ok_or("Forge API returned a non-array pull request list")?;
// A server may cap the requested page size. Only an empty page proves exhaustion.
if values.is_empty() {
return Ok(requests);
}
if page == 1 && forge.provider == Provider::Gitea {
let repo = forge.endpoint.strip_suffix("/pulls").unwrap();
let refs = forge.get(
&format!("{repo}/git/refs/pull"),
token.as_deref(),
root,
cancelled,
)?;
for reference in refs
.as_array()
.ok_or("Gitea API returned a non-array reference list")?
{
let name = field(reference, "ref")?;
if let Some(id) = name
.strip_prefix("refs/pull/")
.and_then(|s| s.strip_suffix("/head"))
{
let id = id
.parse::<u64>()
.ok()
.filter(|n| *n > 0)
.ok_or("Gitea API returned an invalid pull request reference")?;
let object = reference
.get("object")
.ok_or("Gitea API returned no reference object")?;
if field(object, "type")? != "commit"
|| gitea_heads.insert(id, sha(object, "sha")?).is_some()
{
return Err(
"Gitea API returned an invalid or repeated pull request head".into(),
);
}
}
}
}
for value in values {
let id = number(
value,
if forge.provider == Provider::Gitlab {
"iid"
} else {
"number"
},
)?;
if !seen.insert(id) {
return Err(
"Forge API repeated a pull request while paginating; retry the list".into(),
);
}
let detail;
// ponytail: GitLab needs one detail read per MR; batch if large histories become slow.
let value = if forge.provider == Provider::Gitlab
&& value.get("diff_refs").is_none_or(Value::is_null)
{
detail = forge.get(
&format!("{}/{id}", forge.endpoint),
token.as_deref(),
root,
cancelled,
)?;
if number(&detail, "iid")? != id {
return Err("GitLab API returned a different merge request".into());
}
&detail
} else {
value
};
let preserved_head = if forge.provider == Provider::Gitea {
gitea_heads.remove(&id)
} else {
None
};
requests.push(pull_request(forge.provider, value, preserved_head)?);
}
}
Err("Forge pagination overflow".into())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
let gitlab =
Forge::parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap();
assert_eq!(
gitlab.endpoint,
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests"
);
assert_eq!(
Forge::parse("git@github.com:owner/repo.git", None)
.unwrap()
.endpoint,
"https://api.github.com/repos/owner/repo/pulls"
);
assert_eq!(
Forge::parse(
"https://user:secret@forge.example:8443/owner/repo.git",
Some("gitea")
)
.unwrap()
.endpoint,
"https://forge.example:8443/api/v1/repos/owner/repo/pulls"
);
assert!(
Forge::parse("https://forge.example/owner/repo", None)
.err()
.unwrap()
.contains("git config remote.<name>.vamp-forge github")
);
for url in [
"https://github.com/owner/repo?token=secret",
"https://github.com/owner/%2e%2e",
"https://github.com/owner/repo%0a",
"https://github.com:0/owner/repo",
"file:///owner/repo",
] {
assert!(Forge::parse(url, Some("github")).is_err(), "{url}");
}
}
#[test]
fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() {
let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true,
"user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)},
"head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"});
for provider in [Provider::Github, Provider::Gitea] {
let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap();
let revisions = pr.revisions.as_ref().unwrap();
assert_eq!(
(revisions.base_ref.as_str(), revisions.head_ref.as_str()),
("refs/heads/release/next", "refs/pull/7/head")
);
assert_eq!(
revisions.base_sha,
if provider == Provider::Gitea {
"4"
} else {
"1"
}
.repeat(40)
);
assert_eq!(
revisions.head_sha,
if provider == Provider::Gitea {
"5"
} else {
"2"
}
.repeat(40)
);
assert!(pr.closed && pr.description.contains("merged"));
if provider == Provider::Gitea {
assert!(pr.description.contains("fork:topic"));
let mut deleted = value.clone();
deleted["head"]["sha"] = Value::String(String::new());
assert_eq!(
pull_request(provider, &deleted, Some("5".repeat(40)))
.unwrap()
.revisions
.unwrap()
.head_sha,
"5".repeat(40)
);
let unavailable = pull_request(provider, &deleted, None).unwrap();
assert_eq!(unavailable.number, 7);
assert!(unavailable.closed);
assert!(unavailable.description.contains("unavailable"));
assert!(unavailable.revisions.is_err());
}
}
}
#[test]
fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() {
let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"},
"target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}});
let pr = pull_request(Provider::Gitlab, &value, None).unwrap();
let revisions = pr.revisions.unwrap();
assert_eq!(revisions.base_sha, "2".repeat(40));
assert_eq!(revisions.head_ref, "refs/merge-requests/9/head");
value["diff_refs"]["start_sha"] = Value::Null;
assert_eq!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.unwrap()
.base_sha,
"1".repeat(40)
);
value["target_branch"] = json!("main:refs/heads/injected");
assert!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.is_err()
);
value["target_branch"] = json!("main");
value["diff_refs"]["head_sha"] = json!("HEAD");
assert!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.is_err()
);
value["diff_refs"] = Value::Null;
assert!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.is_err()
);
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod clipboard;
pub mod diff;
pub mod editor;
pub mod files;
mod forge;
pub mod gutter;
pub mod lsp;
mod lsp_config;
+2
View File
@@ -81,6 +81,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
Remote refs/history load into temporary comparison repos without changing active refs/checkout
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.
+123 -5
View File
@@ -54,7 +54,7 @@ impl Drop for Temporary {
}
}
struct ProcessOutput {
pub(crate) struct ProcessOutput {
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
@@ -97,7 +97,7 @@ fn terminate(child: &mut std::process::Child) {
let _ = child.kill();
let _ = child.wait();
}
fn run(
pub(crate) fn run(
mut command: Command,
input: Vec<u8>,
cancelled: &AtomicBool,
@@ -200,7 +200,7 @@ fn run(
})
}
}
fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
pub(crate) fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
if output.code == Some(0) {
Ok(output.stdout)
} else {
@@ -1535,9 +1535,69 @@ impl Repository {
self.load_remote(remote, None)?;
self.catalog()
}
pub fn pull_requests(&mut self, name: &str) -> Result<Catalog, String> {
self.discover_remotes()?;
let remote = if name.is_empty() {
self.remotes
.iter()
.find(|remote| remote.name == "origin")
.or_else(|| (self.remotes.len() == 1).then(|| &self.remotes[0]))
.ok_or("Choose a remote with :pull_requests <remote>")?
} else {
self.remotes
.iter()
.find(|remote| remote.name == name)
.ok_or_else(|| format!("Unknown configured remote: {name}"))?
};
let mut config = git_command(&self.store);
config.args([
"config",
"--get",
&format!("remote.{}.vamp-forge", remote.name),
]);
let output = run(config, vec![], &self.cancelled)?;
let provider = if output.code == Some(1) {
None
} else {
Some(text(checked(output)?)?.trim().to_owned())
};
let pulls = crate::forge::list(
&remote.url,
provider.as_deref(),
&self.root,
&self.cancelled,
)
.map_err(|error| error.replace("remote.<name>", &format!("remote.{}", remote.name)))?;
Ok(Catalog {
kind: self.kind,
notice: Some(format!("Pull requests from {}", remote.name)),
entries: pulls
.into_iter()
.map(|pull| Revision {
target: match pull.revisions {
Ok(revisions) => RevisionTarget::PullRequest {
remote: remote.name.clone(),
base_ref: revisions.base_ref,
base_sha: revisions.base_sha,
head_ref: revisions.head_ref,
head_sha: revisions.head_sha,
},
Err(error) => RevisionTarget::UnavailablePullRequest(error),
},
label: format!("#{} {}", pull.number, pull.title),
description: pull.description,
category: if pull.closed {
Category::Closed
} else {
Category::Open
},
})
.collect(),
})
}
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
let id = match target {
RevisionTarget::Commit(id) => {
RevisionTarget::Commit(id) | RevisionTarget::RemoteCommit { id, .. } => {
if !matches!(id.len(), 40 | 64)
|| (self.kind == RepoKind::Jujutsu && id.len() != 40)
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
@@ -1560,7 +1620,10 @@ impl Repository {
})?
.clone(),
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
RevisionTarget::FetchRemote(_) | RevisionTarget::ListRemote(_) => {
RevisionTarget::UnavailablePullRequest(error) => return Err(error.clone()),
RevisionTarget::FetchRemote(_)
| RevisionTarget::ListRemote(_)
| RevisionTarget::PullRequest { .. } => {
return Err("Select a revision, not a history loader".into());
}
};
@@ -1573,6 +1636,61 @@ impl Repository {
return Ok(resolved);
}
}
if let RevisionTarget::RemoteCommit {
remote, reference, ..
} = target
{
if !reference.starts_with("refs/") {
return Err("Invalid pull request reference".into());
}
self.git(&self.store, &["check-ref-format", reference])?;
self.discover_remotes()?;
let url = self
.remotes
.iter()
.find(|entry| &entry.name == remote)
.ok_or_else(|| format!("Unknown configured remote: {remote}"))?
.url
.clone();
self.ensure_private()?;
let store = &self.private.as_ref().unwrap().1;
// Closed PR refs can disappear; servers may still serve their immutable commits.
if self
.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{id}:refs/vamp-pull-request/selected"),
],
)
.is_err()
{
self.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{reference}:refs/vamp-pull-request/selected"),
],
)
.map_err(|error| {
format!("Pull request commit unavailable; reopen :pull_requests: {error}")
})?;
}
if let Some(resolved) = self.resolve_stored(&id)? {
return Ok(resolved);
}
return Err(
"Pull request changed or its commit is unavailable; reopen :pull_requests".into(),
);
}
Err(format!(
"Commit {id} is unavailable locally or in loaded remote history"
))
+3
View File
@@ -70,6 +70,9 @@ impl DiffWorker {
DiffOperation::Discover => {
repository.catalog().map(DiffOutcome::Catalog)
}
DiffOperation::PullRequests(remote) => {
repository.pull_requests(&remote).map(DiffOutcome::Catalog)
}
DiffOperation::ListRemote(remote) => {
repository.list_remote(&remote).map(DiffOutcome::Catalog)
}
+93 -18
View File
@@ -2594,6 +2594,7 @@ fn draw_diff(
return;
}
if diff.phase != Phase::View {
let pull_requests = diff.pull_request_remote.is_some();
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
let stage = if diff.phase == Phase::Before {
@@ -2601,12 +2602,18 @@ fn draw_diff(
} else {
"After"
};
let title = format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label());
let title = if pull_requests {
" Pull requests ".into()
} else {
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
};
let content = diff_panel(frame, panel, &title, "", "", true);
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
Some(if diff.after.is_some() {
"Loading comparison…"
} else if pull_requests {
"Loading pull requests…"
} else {
"Loading repository…"
})
@@ -2629,7 +2636,7 @@ fn draw_diff(
Constraint::Length(u16::from(content.height >= 4)),
Constraint::Length(1),
Constraint::Length(u16::from(notice.is_some() && content.height >= 3)),
Constraint::Length(u16::from(content.height >= 2)),
Constraint::Length(u16::from(!pull_requests && content.height >= 2)),
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 7)),
])
@@ -2638,17 +2645,18 @@ fn draw_diff(
.before
.as_ref()
.map_or("not selected", |revision| revision.label.as_str());
frame.render_widget(
Paragraph::new(format!(
let stage_text = if pull_requests {
"Select a pull request to compare its branch changes".into()
} else {
format!(
"{} · Ctrl+b toggle · Before: {}",
diff.comparison_mode.label(),
display_text(before)
)),
stage_area,
);
if let Some(catalog) = &diff.catalog {
let tabs: Vec<Span> = catalog
.kind
)
};
frame.render_widget(Paragraph::new(stage_text), stage_area);
if pull_requests || diff.catalog.is_some() {
let tabs: Vec<Span> = diff
.categories()
.iter()
.enumerate()
@@ -2696,7 +2704,7 @@ fn draw_diff(
);
}
let mut entries = diff.entries();
if let Some(working) = entries.next() {
if !pull_requests && let Some(working) = entries.next() {
let label = format!(
"{} {} · pinned",
if diff.selected == 0 { ">" } else { " " },
@@ -2721,6 +2729,7 @@ fn draw_diff(
1
};
let count = usize::from(results.height) / row_height;
let pinned = usize::from(!pull_requests);
let mut key = std::collections::hash_map::DefaultHasher::new();
(
diff.phase as u8,
@@ -2735,17 +2744,16 @@ fn draw_diff(
let first = PickerViewport::follow(
&mut panels.picker_scroll.revisions,
key.finish(),
diff.selected.saturating_sub(1),
diff.selected.saturating_sub(pinned),
usize::from(results.height),
row_height,
panels.picker_scroll.distance,
)
.saturating_add(1);
);
let mut lines = Vec::with_capacity(count * row_height);
for (index, revision) in entries
.enumerate()
.map(|(index, revision)| (index + 1, revision))
.skip(first - 1)
.skip(first)
.map(|(index, revision)| (index + pinned, revision))
.take(count)
{
let style = if index == diff.selected {
@@ -2783,13 +2791,23 @@ fn draw_diff(
}
}
if lines.is_empty() && !diff.loading {
frame.render_widget(Paragraph::new("No matching revisions"), results);
let empty = if pull_requests {
"No matching pull requests"
} else {
"No matching revisions"
};
frame.render_widget(Paragraph::new(empty), results);
} else {
frame.render_widget(Paragraph::new(lines), results);
}
let accept = if diff.error.is_some() {
"retry"
} else {
"select"
};
frame.render_widget(
Paragraph::new(format!(
"{navigation_hint} · Tab categories · type search · Enter select · Esc close · Ctrl+p actions"
"{navigation_hint} · Tab categories · type search · Enter {accept} · Esc close · Ctrl+p actions"
))
.style(Style::default().fg(Color::DarkGray)),
help,
@@ -6719,6 +6737,63 @@ mod tests {
);
}
#[test]
fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
for (width, height) in [(100, 16), (44, 12)] {
let mut diff = DiffState::pull_requests("origin".into());
diff.take_request();
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Loading pull requests"));
diff.apply_result(1, Err("offline".into()));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("offline"));
diff.handle(Input::Enter);
diff.take_request();
diff.apply_result(
2,
Ok(crate::diff::Outcome::Catalog(Catalog {
kind: RepoKind::Git,
entries: (0..20)
.map(|index| Revision {
target: RevisionTarget::PullRequest {
remote: "origin".into(),
base_ref: "refs/heads/main".into(),
base_sha: "base".into(),
head_ref: format!("refs/pull/{index}/head"),
head_sha: format!("head-{index}"),
},
label: format!("#{index:02} Pull request"),
description: "open · main ← topic".into(),
category: Category::Open,
})
.collect(),
notice: None,
})),
);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Pull requests"));
assert!(text.contains("Open") && text.contains("Closed") && text.contains("All"));
assert!(text.contains("> #00 Pull request"));
assert!(!text.contains("pinned"));
diff.handle(Input::End);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("> #19 Pull request"));
assert!(!text.contains("#00 Pull request"));
diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("> #00 Pull request"));
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("No matching pull requests"));
}
}
#[test]
fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {
let before: Vec<_> = (0..80)
+77
View File
@@ -1929,3 +1929,80 @@ fn branch_changes_rejects_missing_and_multiple_merge_bases_without_snapshot_fall
}
assert_eq!(disk_state(&root), state);
}
#[test]
fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
let fixture = Fixture::new();
let source = fixture.dir("source");
init_git(&source);
fs::write(source.join("common"), "base\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "base"]);
let base = git(&source, &["rev-parse", "HEAD"]);
fs::write(source.join("target-only"), "not part of the PR\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "target advanced"]);
let target = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["checkout", "--detach", &base]);
fs::write(source.join("common"), "fork change\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "fork tip"]);
let head = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["update-ref", "refs/pull/7/head", &head]);
for (index, kind) in [RepoKind::Git, RepoKind::Jujutsu, RepoKind::Jujutsu]
.into_iter()
.enumerate()
{
let root = fixture.dir(&format!("active-{index}"));
if kind == RepoKind::Git {
init_git(&root);
fs::write(root.join("unrelated"), "local\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "unrelated local"]);
git(
&root,
&["remote", "add", "origin", source.to_str().unwrap()],
);
} else {
init_jj(&root, index == 2);
jj(
&root,
&["git", "remote", "add", "origin", source.to_str().unwrap()],
);
}
fs::write(root.join("unsaved"), "keep\n").unwrap();
let state = disk_state(&root);
let pinned = |reference: &str, id: &str| Revision {
target: RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: reference.into(),
id: id.into(),
},
..revision(id, kind)
};
let before = pinned("refs/heads/local", &target);
let after = pinned("refs/pull/7/head", &head);
let mut repository = open(&root);
let comparison = repository
.compare(70, &before, &after, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(comparison.before.ends_with(&base));
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("common"));
assert!(comparison.editable_paths.is_empty());
let file = repository.load_file(70, 0).unwrap();
assert_eq!(file.before, ["base"]);
assert_eq!(file.after, ["fork change"]);
assert_eq!(disk_state(&root), state);
// A force-push cannot silently replace the immutable tip selected in the picker.
let absent = pinned("refs/pull/7/head", &"f".repeat(40));
assert!(
repository
.compare(71, &before, &absent, ComparisonMode::BranchChanges, &[])
.unwrap_err()
.contains("reopen :pull_requests")
);
assert_eq!(disk_state(&root), state);
}
}