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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ Layer 5 (binary): code_assistant — CLI, config, feature-gated frontend
- Client and snapshot dir injected via `AgentRuntimeOptions::pull_requests`;
the lifecycle sweep reads sessions' PRs through it in batches; see
`docs/pull-request-view.md`
- A project folder holding several checkouts offers a choice of repository
above the list (`session_checkouts`, `right_panel/pull_request/repo_picker.rs`);
tabs are keyed by `PullRequestRef` and stay open when the list moves on
- Checkout like `gh pr checkout` (`git::pull_request`, here or in a new
worktree session) and stack rebases (`git::stack_rebase`, scratch repo)
are git-crate work the core drives
Expand Down
22 changes: 21 additions & 1 deletion crates/code_assistant_core/src/pull_requests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ pub use store::{PullRequestSource, PullRequests};

pub(crate) use checkout::pull_request_head;

pub(crate) use repo::{github_remote, github_repo, own_pull_request};
pub(crate) use repo::{
github_checkouts, github_remote, github_repo, own_pull_request, working_branch,
};

use github::RepoRef;
use std::path::PathBuf;
use std::time::SystemTime;

/// A cached read: the value plus whether a background refresh is under way
Expand All @@ -52,6 +55,23 @@ pub enum PullRequestPart {
Stack,
}

/// A checkout of a GitHub repository.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoCheckout {
pub path: PathBuf,
pub repo: RepoRef,
/// The branch checked out, unless it is the base branch.
pub branch: Option<String>,
}

/// The repositories a session can look at: that of its directory and those
/// checked out directly below it, as in a project folder holding several.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionCheckouts {
pub directory: PathBuf,
pub checkouts: Vec<RepoCheckout>,
}

/// Where a session's pull requests live and which one is its own.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionPullRequests {
Expand Down
118 changes: 117 additions & 1 deletion crates/code_assistant_core/src/pull_requests/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

use github::RepoRef;
use github::model::HeadPullRequest;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

use super::RepoCheckout;

/// The GitHub repository of a checkout: its `origin` remote, else the first
/// remote (by name) whose URL names a GitHub repository.
Expand All @@ -29,6 +31,53 @@ pub(crate) async fn github_repo(path: PathBuf) -> Option<RepoRef> {
.flatten()
}

/// The branch being worked on in a checkout: the one checked out, unless it
/// is the base branch.
pub(crate) fn working_branch(repo: &git::GitRepository) -> Option<String> {
repo.current_branch().filter(|branch| {
!repo
.default_base_branch()
.is_some_and(|base| crate::session::lifecycle::is_base_branch(branch, &base))
})
}

/// The GitHub repositories at `dir`: its own, then those of the checkouts
/// directly below it, by directory name. Hidden directories are not looked
/// into, so worktrees under `.worktrees` do not count, and a repository
/// checked out twice is listed once.
pub(crate) fn github_checkouts(dir: &Path) -> Vec<RepoCheckout> {
let mut below: Vec<PathBuf> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|path| {
let hidden = path
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with('.'));
!hidden && path.join(".git").exists()
})
.collect();
below.sort_by_key(|path| path.to_string_lossy().to_lowercase());
let mut checkouts: Vec<RepoCheckout> = Vec::new();
for path in std::iter::once(dir.to_path_buf()).chain(below) {
let Ok(opened) = git::GitRepository::open(&path) else {
continue;
};
let Some(repo) = github_remote(&opened) else {
continue;
};
if checkouts.iter().all(|checkout| checkout.repo != repo) {
checkouts.push(RepoCheckout {
branch: working_branch(&opened),
path,
repo,
});
}
}
checkouts
}

/// Among the pull requests GitHub found for a branch (newest first), the
/// newest opened from the repository itself, not from a fork that happens
/// to use the same branch name.
Expand Down Expand Up @@ -61,6 +110,73 @@ mod tests {
git::GitRepository::open(dir).unwrap()
}

/// A checkout of `github.com/team/<name>` below `dir`.
fn checkout_below(dir: &Path, directory: &str, name: &str) -> std::path::PathBuf {
let path = dir.join(directory);
std::fs::create_dir_all(&path).unwrap();
let url = format!("git@github.com:team/{name}.git");
repo_with_remotes(&path, &[("origin", url.as_str())]);
path
}

fn names(checkouts: &[RepoCheckout]) -> Vec<&str> {
checkouts.iter().map(|c| c.repo.name.as_str()).collect()
}

#[test]
fn a_folder_of_checkouts_lists_each_by_directory_name() {
let tmp = tempfile::tempdir().unwrap();
let beta = checkout_below(tmp.path(), "Beta", "beta");
checkout_below(tmp.path(), "alpha", "alpha");
// Neither a checkout nor one of GitHub's.
std::fs::create_dir(tmp.path().join("notes")).unwrap();
let mirror = tmp.path().join("mirror");
std::fs::create_dir(&mirror).unwrap();
repo_with_remotes(&mirror, &[("origin", "/srv/mirrors/tool.git")]);

let found = github_checkouts(tmp.path());
assert_eq!(names(&found), ["alpha", "beta"]);
assert_eq!(found[1].path, beta);
}

#[test]
fn the_folders_own_repository_comes_first() {
let tmp = tempfile::tempdir().unwrap();
repo_with_remotes(tmp.path(), &[("origin", "git@github.com:team/root.git")]);
checkout_below(tmp.path(), "nested", "nested");

let found = github_checkouts(tmp.path());
assert_eq!(names(&found), ["root", "nested"]);
assert_eq!(found[0].path, tmp.path());
}

#[test]
fn hidden_directories_and_second_checkouts_do_not_count() {
let tmp = tempfile::tempdir().unwrap();
checkout_below(tmp.path(), "first", "tool");
checkout_below(tmp.path(), "second", "tool");
checkout_below(tmp.path(), ".worktrees", "hidden");

assert_eq!(names(&github_checkouts(tmp.path())), ["tool"]);
}

#[test]
fn a_checkout_on_its_base_branch_has_no_working_branch() {
let tmp = tempfile::tempdir().unwrap();
let path = checkout_below(tmp.path(), "tool", "tool");
git(&path, &["config", "user.email", "t@t.t"]);
git(&path, &["config", "user.name", "t"]);
git(&path, &["config", "commit.gpgsign", "false"]);
git(&path, &["commit", "-q", "--allow-empty", "-m", "init"]);
assert_eq!(github_checkouts(tmp.path())[0].branch, None);

git(&path, &["checkout", "-q", "-b", "feature/x"]);
assert_eq!(
github_checkouts(tmp.path())[0].branch.as_deref(),
Some("feature/x")
);
}

#[test]
fn origin_wins_over_other_github_remotes() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
11 changes: 4 additions & 7 deletions crates/code_assistant_core/src/pull_requests/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

use super::cache::{Cache, Policy, Refreshed};
use super::snapshots::{Kind, Snapshots};
use super::{Fetched, PullRequestPart, SessionPullRequests, github_remote, own_pull_request};
use super::{
Fetched, PullRequestPart, SessionPullRequests, github_remote, own_pull_request, working_branch,
};
use crate::session::event_stream::EventStream;
use crate::ui::UiEvent;
use anyhow::Result;
Expand Down Expand Up @@ -230,12 +232,7 @@ impl PullRequests {
let checkout = tokio::task::spawn_blocking(move || {
let repo = git::GitRepository::open(&path).ok()?;
let github = github_remote(&repo)?;
let checked_out = repo.current_branch().filter(|branch| {
!repo
.default_base_branch()
.is_some_and(|base| crate::session::lifecycle::is_base_branch(branch, &base))
});
Some((github, checked_out))
Some((github, working_branch(&repo)))
})
.await?;
let Some((repo, checked_out)) = checkout else {
Expand Down
Loading
Loading