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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ sections remain directly above the first visible branch they own.
| `a` | Toggle Active / Archive view |
| `X`, then `y` / `n` | Confirm or cancel guarded deletion of one exact local branch |
| `r` | Force repository reconciliation |
| `o` / `y` | Open or copy the selected PR URL |
| `o` / `O` / `y` | Open the selected PR URL, open every PR in the selected stack, or copy the selected PR URL |
| `?` | Show help and provider status |
| `Esc` | Close help/message or cancel a filter edit |
| `q`, `Ctrl-C` | Quit |
Expand Down Expand Up @@ -214,17 +214,19 @@ missing, busy, or corrupt metadata produces an explicit topology-unavailable
state; every Git-local branch remains visible as an independent root. The tool
never writes or migrates Graphite files.

GitHub enrichment is optional. A single-flight, TTL-limited bounded `gh pr list`
request retrieves PRs in all states, and results attach only when branch name and tip
object ID still match. Missing auth, offline operation, timeout, or malformed
JSON is shown as provider state and does not affect local navigation.
GitHub enrichment is optional. A single-flight, TTL-limited bounded `gh pr list --state open`
request attaches PRs by local branch name. A matching tip object ID is preferred when
several PRs share a name, but drifted local commits still get the branch PR so `o` / `O`
can open it. Missing auth, offline operation, timeout, or malformed JSON is shown as
provider state (`?` help, GitHub line) and does not affect local navigation.

## Troubleshooting

| Symptom | Meaning / action |
|---|---|
| `topology unavailable` | Graphite metadata is missing or incompatible; Git branches are still complete |
| PR details are blank | Run `gh auth status`; local behavior does not require GitHub |
| PR details are blank | Wait until `?` shows `GitHub: loaded`; run `gh auth status`; press `r` to retry. Local navigation does not require GitHub |
| `o` does nothing | Select a branch row (not a stack label), wait for a yellow `#N`, then press `o`. Hover does not open. Archive view and `/` search block `o`. |
| Checkout is disabled | The branch is current, another worktree owns it, or Git has an operation in progress |
| Git blocks checkout | Commit/move the conflicting work yourself; stackmap deliberately performs no cleanup |
| Shift-arrow does not jump | This is terminal encoding, not Vim. Use `J` / `K`, or configure the terminal to send `ESC [ 1 ; 2 A` / `ESC [ 1 ; 2 B` for Shift+Up / Shift+Down. Stackmap enables complete modifier reporting when the terminal supports the enhanced keyboard protocol. |
Expand Down
42 changes: 22 additions & 20 deletions src/adapters/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use serde::Deserialize;
use super::command::{CommandError, CommandOutput, run_bounded};
use crate::model::{BranchId, PullRequest, PullRequestStatus};

const GITHUB_LIST_TIMEOUT: Duration = Duration::from_secs(30);
const GITHUB_LIST_OUTPUT_LIMIT: usize = 8 * 1024 * 1024;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhPullRequest {
Expand Down Expand Up @@ -75,7 +78,7 @@ pub fn fetch(cwd: &Path) -> Result<Vec<PrMatch>, GitHubError> {
"pr",
"list",
"--state",
"all",
"open",
"--limit",
"1000",
"--json",
Expand All @@ -85,8 +88,8 @@ pub fn fetch(cwd: &Path) -> Result<Vec<PrMatch>, GitHubError> {
OsStr::new("gh"),
args,
cwd,
Duration::from_secs(3),
4 * 1024 * 1024,
GITHUB_LIST_TIMEOUT,
GITHUB_LIST_OUTPUT_LIMIT,
)
.map_err(GitHubError::from)?;
parse_output(output)
Expand All @@ -109,24 +112,23 @@ pub fn parse_json(bytes: &[u8]) -> Result<Vec<PrMatch>, GitHubError> {
.map_err(|error| GitHubError::Malformed(Arc::from(error.to_string())))?;
Ok(values
.into_iter()
.filter_map(|value| {
Some(PrMatch {
branch: BranchId::new(value.head_ref_name),
oid: Arc::from(value.head_ref_oid?),
pull_request: PullRequest {
number: value.number,
title: Arc::from(value.title),
url: Arc::from(value.url),
status: match value.state.as_str() {
"MERGED" => PullRequestStatus::Merged,
"CLOSED" => PullRequestStatus::Closed,
_ if value.review_decision.as_deref() == Some("APPROVED") => {
PullRequestStatus::Approved
}
_ => PullRequestStatus::Open,
},
.filter(|value| !value.head_ref_name.is_empty())
.map(|value| PrMatch {
branch: BranchId::new(value.head_ref_name),
oid: Arc::from(value.head_ref_oid.unwrap_or_default()),
pull_request: PullRequest {
number: value.number,
title: Arc::from(value.title),
url: Arc::from(value.url),
status: match value.state.as_str() {
"MERGED" => PullRequestStatus::Merged,
"CLOSED" => PullRequestStatus::Closed,
_ if value.review_decision.as_deref() == Some("APPROVED") => {
PullRequestStatus::Approved
}
_ => PullRequestStatus::Open,
},
})
},
})
.collect())
}
Expand Down
10 changes: 9 additions & 1 deletion src/adapters/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ const PLATFORM_TIMEOUT: Duration = Duration::from_secs(3);
const OUTPUT_LIMIT: usize = 64 * 1024;

pub fn open_url(url: &str) -> Result<()> {
open_urls(&[url])
}

pub fn open_urls(urls: &[impl AsRef<str>]) -> Result<()> {
if urls.is_empty() {
return Ok(());
}
let cwd = std::env::current_dir()?;
let arguments: Vec<&str> = urls.iter().map(|url| url.as_ref()).collect();
let output = run_bounded(
OsStr::new("open"),
[url],
arguments,
&cwd,
PLATFORM_TIMEOUT,
OUTPUT_LIMIT,
Expand Down
74 changes: 59 additions & 15 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::model::topology::{
ArchiveMode, Emphasis, OrderMode, ProjectionOptions, ProjectionScope, TopologyIndex,
TopologyProjection,
};
use crate::model::{Branch, BranchId, RepositorySnapshot};
use crate::model::{Branch, BranchId, PullRequest, PullRequestStatus, RepositorySnapshot};
use crate::refresh::upstream::{
MAX_TARGETS, UpstreamBatch, UpstreamCommand, UpstreamRequest, UpstreamTarget,
};
Expand Down Expand Up @@ -228,24 +228,22 @@ impl App {
Vec::new()
};
if let Some(current) = &self.snapshot {
let current_prs: std::collections::HashMap<_, _> = current
let current_prs: HashMap<BranchId, PullRequest> = current
.branches
.iter()
.filter_map(|branch| {
branch
.pr
.clone()
.map(|pr| ((branch.id.clone(), branch.oid.clone()), pr))
.map(|pull_request| (branch.id.clone(), pull_request))
})
.collect();
if !current_prs.is_empty() {
let snapshot = Arc::make_mut(&mut snapshot);
let branches = Arc::make_mut(&mut snapshot.branches);
for branch in branches {
if branch.pr.is_none() {
branch.pr = current_prs
.get(&(branch.id.clone(), branch.oid.clone()))
.cloned();
branch.pr = current_prs.get(&branch.id).cloned();
}
}
}
Expand Down Expand Up @@ -526,15 +524,7 @@ impl App {
};
let mut branches = snapshot.branches.to_vec();
for branch in &mut branches {
branch.pr = None;
}
for result in matches {
if let Some(branch) = branches
.iter_mut()
.find(|branch| branch.id == result.branch && branch.oid == result.oid)
{
branch.pr = Some(result.pull_request);
}
branch.pr = pick_best_pull_request(&matches, branch);
}
self.snapshot = Some(Arc::new(RepositorySnapshot {
branches: Arc::from(branches),
Expand Down Expand Up @@ -794,6 +784,10 @@ impl App {
.selected_url()
.map(Action::OpenUrl)
.unwrap_or(Action::None),
Key::Character('O') => self
.stack_pr_urls()
.map(Action::OpenUrls)
.unwrap_or(Action::None),
Key::Character('y') => self
.selected_url()
.map(Action::CopyUrl)
Expand Down Expand Up @@ -2090,6 +2084,32 @@ impl App {
self.selected_branch()?.pr.as_ref().map(|pr| pr.url.clone())
}

fn stack_pr_urls(&self) -> Option<Vec<Arc<str>>> {
if matches!(self.archive_mode, ArchiveMode::Archive) || self.selected_label.is_some() {
return None;
}
let (selected, snapshot, topology) = match (&self.selected, &self.snapshot, &self.topology)
{
(Some(selected), Some(snapshot), Some(topology)) => (selected, snapshot, topology),
_ => return None,
};
let stack_branches = topology.stack_branches(selected)?;
let mut urls = Vec::new();
let mut seen = HashSet::new();
for branch_id in stack_branches {
let Some(pull_request) = snapshot
.branch(branch_id)
.and_then(|branch| branch.pr.as_ref())
else {
continue;
};
if seen.insert(Arc::clone(&pull_request.url)) {
urls.push(Arc::clone(&pull_request.url));
}
}
if urls.is_empty() { None } else { Some(urls) }
}

pub fn begin_delete_confirmation(&mut self) {
if self.selected_label.is_some() {
self.message = Some(Arc::from("labels cannot be deleted"));
Expand Down Expand Up @@ -3061,3 +3081,27 @@ fn reconciliation_notice(
}
}
}

fn pick_best_pull_request(matches: &[PrMatch], branch: &Branch) -> Option<PullRequest> {
matches
.iter()
.filter(|candidate| candidate.branch == branch.id)
.max_by_key(|candidate| pull_request_match_score(candidate, &branch.oid))
.map(|matched| matched.pull_request.clone())
}

fn pull_request_match_score(pr_match: &PrMatch, branch_oid: &str) -> (u8, bool, u64) {
(
pull_request_status_rank(pr_match.pull_request.status),
pr_match.oid.as_ref() == branch_oid,
pr_match.pull_request.number,
)
}

fn pull_request_status_rank(status: PullRequestStatus) -> u8 {
match status {
PullRequestStatus::Open | PullRequestStatus::Approved => 2,
PullRequestStatus::Closed => 1,
PullRequestStatus::Merged => 0,
}
}
1 change: 1 addition & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub enum Action {
Checkout(BranchId),
Delete(DeleteRequest),
OpenUrl(Arc<str>),
OpenUrls(Vec<Arc<str>>),
CopyUrl(Arc<str>),
PersistConfig(ConfigWriteRequest),
}
Expand Down
Loading