update
This commit is contained in:
@@ -1,28 +1,3 @@
|
||||
//! Local checkout associations ("remember" tier of the PR suggestions):
|
||||
//! which local folders are checkouts of which announced repositories.
|
||||
//!
|
||||
//! Two sources feed the resolution:
|
||||
//!
|
||||
//! - **Remembered records** (settings, [`settings::CheckoutRecord`]):
|
||||
//! recorded when the user clones a repository from the app or picks a
|
||||
//! folder in the New PR panel.
|
||||
//! - **Implicit matches** over the local scan ([`LocalReposStore`]): a
|
||||
//! scanned repository whose `origin` URL matches an announcement `clone`
|
||||
//! URL (scheme-insensitive), or whose root commit equals an announcement
|
||||
//! EUC, is a checkout of that announced repository.
|
||||
//!
|
||||
//! The store also computes per-checkout statuses for two surfaces:
|
||||
//!
|
||||
//! - **"Ready to contribute"** (pull-request banner of repositories the
|
||||
//! user does not own): branch, base and commits ahead of the base.
|
||||
//! - **"Ready to push"** (sidebar badge and banner of the user's own
|
||||
//! repositories): the checked-out branch has commits the grasp servers
|
||||
//! do not have yet (counted against the refreshed remote-tracking
|
||||
//! refs), so the user can push their local work from the app.
|
||||
//!
|
||||
//! Everything is resolved on background threads and swapped in as
|
||||
//! [`Arc`]s; the UI never waits for git.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -40,18 +15,17 @@ use crate::git_store::GitStore;
|
||||
use crate::local_repos::LocalReposStore;
|
||||
use crate::repo_list::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-computation, so bursts
|
||||
/// of notifications (settings edits, rescan ticks) collapse into one pass.
|
||||
/// Delay between a refresh request and the actual re-computation.
|
||||
/// Bursts of notifications, settings edits and rescan ticks, collapse into one pass.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How often the statuses of open repository panels are refreshed, so a
|
||||
/// checkout committed to or pulled in external git surfaces in the banner
|
||||
/// without reopening the panel.
|
||||
/// How often the statuses of open repository panels are refreshed.
|
||||
/// A commit or pull in external git surfaces in the banner without reopening the panel.
|
||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Background poll interval for the "ready to push" badges of the user's
|
||||
/// own repositories when no repository panel is open (each cycle refreshes
|
||||
/// the remote view of the checkouts with a git fetch).
|
||||
/// Background poll interval for the `ready to push` badges of the user's own repositories.
|
||||
/// Used when no repository panel is open.
|
||||
/// Each cycle refreshes the remote view of the checkouts with a git fetch.
|
||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Maximum checkouts considered per repository when computing statuses.
|
||||
@@ -61,24 +35,25 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
||||
|
||||
impl Global for GlobalCheckoutsStore {}
|
||||
|
||||
/// One associated local checkout of a repository, with the git facts needed
|
||||
/// to suggest a pull request.
|
||||
/// One associated local checkout of a repository.
|
||||
/// Carries the git facts needed to suggest a pull request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckoutStatus {
|
||||
/// The checkout folder.
|
||||
pub path: PathBuf,
|
||||
/// The branch checked out (`None`-less: detached checkouts are idle).
|
||||
/// The branch checked out. A detached checkout is idle and yields no status.
|
||||
pub branch: String,
|
||||
/// Commit the branch points at, for tip-based PR dedupe.
|
||||
pub head: String,
|
||||
/// What the branch is compared against. For ready-to-contribute
|
||||
/// statuses: the announced HEAD branch (else `main`, else the first
|
||||
/// local branch). For ready-to-push statuses: the remote-tracking ref
|
||||
/// the unpushed commits are counted against
|
||||
/// (`refs/remotes/origin/<branch>`, or `origin/HEAD` for branches the
|
||||
/// remote does not have yet).
|
||||
/// What the branch is compared against.
|
||||
/// For ready-to-contribute statuses, the announced HEAD branch.
|
||||
/// The fallbacks are `main`, then the first local branch.
|
||||
/// For ready-to-push statuses, the remote-tracking ref.
|
||||
/// Unpushed commits are counted against it.
|
||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||
pub base: String,
|
||||
/// Commits in `base..branch`; always > 0 (even checkouts are dropped).
|
||||
/// Commits in `base..branch`.
|
||||
/// Zero-ahead checkouts are dropped, so this is always above zero.
|
||||
pub ahead: u32,
|
||||
}
|
||||
|
||||
@@ -91,23 +66,23 @@ struct Remembered {
|
||||
|
||||
/// Global store of local-checkout associations and per-checkout statuses.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository: remembered records
|
||||
/// (freshest first) plus scanned repos matched implicitly, deduplicated
|
||||
/// by path. Missing directories are dropped before publishing.
|
||||
/// Checkout paths per announced repository.
|
||||
/// Remembered records, freshest first, plus scanned repos matched implicitly.
|
||||
/// Deduplicated by path.
|
||||
/// Missing directories are dropped before publishing.
|
||||
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||
/// Ready-to-contribute statuses of the requested repositories.
|
||||
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Repositories whose statuses are recomputed whenever the inputs
|
||||
/// change (the repository detail panels currently open).
|
||||
/// Repositories whose statuses are recomputed on every input change.
|
||||
/// Those are the repository detail panels currently open.
|
||||
status_requested: HashSet<RepoAddr>,
|
||||
/// Repositories whose "ready to push" statuses are recomputed on the
|
||||
/// same cycle (the sidebar rows of the user's own repositories, plus
|
||||
/// the detail panels of those repositories).
|
||||
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
|
||||
/// The sidebar rows of the user's own repositories and their detail panels.
|
||||
push_requested: HashSet<RepoAddr>,
|
||||
/// Ready-to-push statuses of the requested own repositories.
|
||||
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Announced head branch last provided per requested repository, so a
|
||||
/// recompute defaults the base the same way.
|
||||
/// Last announced head branch per requested repository.
|
||||
/// A recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
@@ -127,9 +102,10 @@ impl CheckoutsStore {
|
||||
cx.set_global(GlobalCheckoutsStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store: observe the inputs (settings records, the local
|
||||
/// scan, the announcement list, signer changes) and resolve the
|
||||
/// associations right away.
|
||||
/// Create the store.
|
||||
/// Observe the inputs, settings records, the local scan and the announcement list.
|
||||
/// Signer changes also trigger a refresh.
|
||||
/// Associations are resolved right away.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
@@ -148,8 +124,8 @@ impl CheckoutsStore {
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
// Another identity's repositories must not keep the previous
|
||||
// user's statuses (or polls) alive.
|
||||
// Another identity's repositories must not keep the old statuses alive.
|
||||
// Their polls stop too.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if matches!(event, BackendEvent::SignerChanged) {
|
||||
this.status_requested.clear();
|
||||
@@ -182,8 +158,9 @@ impl CheckoutsStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use: (re)insert the record with
|
||||
/// a fresh timestamp, so freshest-first ordering follows actual use.
|
||||
/// Remember a successful local-checkout use.
|
||||
/// Re-insert the record with a fresh timestamp.
|
||||
/// Freshest-first ordering then follows actual use.
|
||||
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
@@ -212,16 +189,15 @@ impl CheckoutsStore {
|
||||
});
|
||||
}
|
||||
|
||||
/// The associated checkouts of `addr`, freshest first. Empty when none
|
||||
/// are known (or the resolution has not run yet).
|
||||
/// The associated checkouts of `addr`, freshest first.
|
||||
/// Empty when none are known or the resolution has not run yet.
|
||||
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
||||
self.by_repo.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the "ready to contribute" statuses of `addr` to be kept
|
||||
/// current (called while the repository's detail panel is open).
|
||||
/// `announced_head` is the announced HEAD branch of the repository
|
||||
/// (from its state announcement), used to default the base.
|
||||
/// Ask for the `ready to contribute` statuses of `addr` to stay current.
|
||||
/// Called while the repository's detail panel is open.
|
||||
/// `announced_head` is the announced HEAD branch, used to default the base.
|
||||
pub fn request_statuses(
|
||||
&mut self,
|
||||
addr: &RepoAddr,
|
||||
@@ -235,33 +211,32 @@ impl CheckoutsStore {
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// The ready-to-contribute statuses of `addr`; empty while none are
|
||||
/// known or nothing is ahead.
|
||||
/// The ready-to-contribute statuses of `addr`.
|
||||
/// Empty while none are known or nothing is ahead.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the "ready to push" statuses of `addr` to be kept current
|
||||
/// (called by the sidebar for the signed-in user's own repositories and
|
||||
/// by the detail panels of those repositories). Recomputed on every
|
||||
/// input change and on a background poll; each cycle refreshes the
|
||||
/// remote view of the checkouts first, so a commit made in external
|
||||
/// git surfaces within one poll interval.
|
||||
/// Ask for the `ready to push` statuses of `addr` to stay current.
|
||||
/// The sidebar and the detail panels call this for the user's own repositories.
|
||||
/// Recomputed on every input change and on a background poll.
|
||||
/// Each cycle refreshes the remote view first.
|
||||
/// A commit made in external git surfaces within one poll interval.
|
||||
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
|
||||
self.push_requested.insert(addr.clone());
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// The ready-to-push statuses of `addr` (only meaningful for
|
||||
/// repositories announced by the signed-in user); empty while none are
|
||||
/// known or nothing is unpushed.
|
||||
/// The ready-to-push statuses of `addr`.
|
||||
/// Only meaningful for repositories announced by the signed-in user.
|
||||
/// Empty while none are known or nothing is unpushed.
|
||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Re-resolve associations (and the requested statuses). Debounced:
|
||||
/// bursts of notifications collapse into one pass; requests arriving
|
||||
/// while a pass runs are folded into a follow-up.
|
||||
/// Re-resolve the associations and the requested statuses.
|
||||
/// Debounced, bursts of notifications collapse into one pass.
|
||||
/// Requests arriving while a pass runs fold into a follow-up.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
@@ -284,7 +259,7 @@ impl CheckoutsStore {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// One resolve + apply cycle (debounced entry point).
|
||||
/// One resolve and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
|
||||
@@ -321,12 +296,12 @@ impl CheckoutsStore {
|
||||
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
// Read the git facts of every scanned repository off the main
|
||||
// thread: origin URL and root commit (both CLI reads).
|
||||
// Read the git facts of every scanned repository off the main thread.
|
||||
// The facts are the origin URL and the root commit, both CLI reads.
|
||||
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||
for path in scanned.iter() {
|
||||
// The browser's mirror clones share the announce URLs and
|
||||
// EUCs; they are not user checkouts.
|
||||
// The browser's mirror clones share the announce URLs and EUCs.
|
||||
// They are not user checkouts.
|
||||
if cache_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| path.starts_with(root))
|
||||
@@ -339,7 +314,7 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
||||
// Missing directories are stale records; drop them.
|
||||
// Missing directories are stale records, drop them.
|
||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||
.into_iter()
|
||||
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
||||
@@ -382,7 +357,7 @@ impl CheckoutsStore {
|
||||
let (associations, statuses, push_statuses) = match work.await {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
// Git reads are best-effort; keep the last results.
|
||||
// Git reads are best-effort, keep the last results.
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
});
|
||||
@@ -408,16 +383,16 @@ impl CheckoutsStore {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
// While any repository panel is open (or any of the user's own
|
||||
// repositories is watched for the sidebar badge), keep the
|
||||
// statuses current: local commits, pulls and branch switches
|
||||
// happen outside the app and are not otherwise observable.
|
||||
// Keep the statuses current while any repository panel is open.
|
||||
// The user's own repositories also count when watched for the sidebar badge.
|
||||
// Local commits, pulls and branch switches happen outside the app.
|
||||
// They are not otherwise observable.
|
||||
this.update(cx, |this, cx| {
|
||||
if poll && !this.debouncing && !this.refreshing {
|
||||
this.debouncing = true;
|
||||
// Open panels get the fast cadence; the sidebar badges
|
||||
// alone poll less aggressively (each cycle fetches
|
||||
// every watched checkout's remote).
|
||||
// Open panels get the fast cadence.
|
||||
// Sidebar-only badges poll less aggressively.
|
||||
// Each cycle fetches every watched checkout's remote.
|
||||
let delay = if this.status_requested.is_empty() {
|
||||
PUSH_POLL
|
||||
} else {
|
||||
@@ -439,11 +414,11 @@ impl CheckoutsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity of a repository URL: host, explicit port and path with a
|
||||
/// trailing `.git` (and slashes) stripped. Scheme-insensitive, so
|
||||
/// `ws`/`wss`/`http`/`https`/`grasp` are equivalent transports of the same
|
||||
/// grasp server. `None` for URLs that cannot be parsed (e.g. `git@`-style
|
||||
/// or plain paths), which then compare by raw string.
|
||||
/// Identity of a repository URL.
|
||||
/// Host, explicit port and path count, with a trailing `.git` and slashes stripped.
|
||||
/// Scheme-insensitive, so `ws`, `wss`, `http`, `https` and `grasp` are one transport.
|
||||
/// `None` for unparseable URLs, e.g. `git@`-style or plain paths.
|
||||
/// Those then compare by raw string.
|
||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
@@ -454,8 +429,8 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
Some((host, parsed.port(), path))
|
||||
}
|
||||
|
||||
/// Whether two repository URLs point at the same repository, ignoring the
|
||||
/// transport scheme (see [`url_identity`]).
|
||||
/// Whether two repository URLs point at the same repository.
|
||||
/// Ignores the transport scheme, see [`url_identity`].
|
||||
fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
match (url_identity(a), url_identity(b)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
@@ -463,10 +438,10 @@ fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the associations between local checkouts and announced
|
||||
/// repositories: remembered records (freshest first per repository),
|
||||
/// followed by scanned repositories matched by origin URL or EUC.
|
||||
/// Deduplicated by path, keeping the first (remembered) occurrence.
|
||||
/// Resolve the associations between local checkouts and announced repositories.
|
||||
/// Remembered records come first, freshest first per repository.
|
||||
/// Scanned repositories matched by origin URL or EUC follow.
|
||||
/// Deduplicated by path, remembered entries win.
|
||||
fn resolve_associations<'a>(
|
||||
remembered: &[Remembered],
|
||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||
@@ -507,8 +482,9 @@ fn resolve_associations<'a>(
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the worktree of `path` has uncommitted changes (a dirty
|
||||
/// checkout is never suggested: the proposal should cover committed work).
|
||||
/// Whether the worktree of `path` has uncommitted changes.
|
||||
/// A dirty checkout is never suggested.
|
||||
/// The proposal should cover committed work.
|
||||
fn worktree_dirty(path: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -522,8 +498,9 @@ fn worktree_dirty(path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `path` (`git rev-list
|
||||
/// --count`); `0` when the range is empty or cannot be computed.
|
||||
/// Commits in `base..branch` of the checkout at `path`.
|
||||
/// Reads `git rev-list --count`.
|
||||
/// `0` when the range is empty or cannot be computed.
|
||||
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -540,8 +517,8 @@ fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch checked out at `path` (`git branch --show-current`), `None`
|
||||
/// when detached.
|
||||
/// The branch checked out at `path`, read via `git branch --show-current`.
|
||||
/// `None` when detached.
|
||||
fn current_branch_of(path: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -554,10 +531,11 @@ fn current_branch_of(path: &Path) -> Option<String> {
|
||||
(!branch.is_empty()).then_some(branch)
|
||||
}
|
||||
|
||||
/// The ready-to-contribute status of one checkout, or `None` when it is
|
||||
/// idle: detached HEAD, no branches, a dirty worktree, or nothing ahead of
|
||||
/// its base. The base defaults like the New PR panel: the announced HEAD
|
||||
/// branch when the checkout has it, else `main`, else the first branch.
|
||||
/// The ready-to-contribute status of one checkout.
|
||||
/// `None` when idle.
|
||||
/// Idle means detached HEAD, no branches, a dirty worktree or nothing ahead of its base.
|
||||
/// The base defaults like the New PR panel.
|
||||
/// The announced HEAD branch when the checkout has it, else `main`, else the first branch.
|
||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||
let branches = signed_git::worktree_branches(path).ok()?;
|
||||
if branches.is_empty() || worktree_dirty(path) {
|
||||
@@ -583,8 +561,8 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the reference `name` (e.g. `refs/remotes/origin/main`) exists
|
||||
/// in the checkout at `path`.
|
||||
/// Whether the reference `name` exists in the checkout at `path`.
|
||||
/// Example, `refs/remotes/origin/main`.
|
||||
fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -595,13 +573,12 @@ fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
matches!(output, Ok(output) if output.status.success())
|
||||
}
|
||||
|
||||
/// The "ready to push" status of one checkout of the user's own
|
||||
/// repository: the checked-out branch has commits the grasp servers do not
|
||||
/// have yet. The remote view is refreshed first (best-effort: offline, the
|
||||
/// last known remote state still counts the commits made since). Detached
|
||||
/// checkouts, dirty worktrees and branches with no remote state at all
|
||||
/// (the remote HEAD is unknown) are never suggested; branches the remote
|
||||
/// does not have yet are counted against the remote HEAD.
|
||||
/// The `ready to push` status of one checkout of the user's own repository.
|
||||
/// The checked-out branch has commits the grasp servers do not have yet.
|
||||
/// The remote view is refreshed first, best-effort.
|
||||
/// Offline, the last known remote state still counts commits made since.
|
||||
/// Detached checkouts, dirty worktrees and an unknown remote state yield no status.
|
||||
/// Branches the remote does not have yet are counted against the remote HEAD.
|
||||
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
if worktree_dirty(path) {
|
||||
return None;
|
||||
@@ -610,13 +587,13 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||
|
||||
// Refresh the remote heads so a commit made elsewhere (or pushed from
|
||||
// another machine) does not show as "to push" forever.
|
||||
// Refresh the remote heads first.
|
||||
// Commits made elsewhere or pushed from another machine must not linger as `to push`.
|
||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
||||
|
||||
let remote = format!("refs/remotes/origin/{branch}");
|
||||
// A branch that has never been fetched/pushed yet is compared against
|
||||
// the remote HEAD (its fork point in practice).
|
||||
// A branch never fetched or pushed yet compares against the remote HEAD.
|
||||
// The remote HEAD is the fork point in practice.
|
||||
let base = if ref_exists(path, &remote) {
|
||||
remote
|
||||
} else if ref_exists(path, "refs/remotes/origin/HEAD") {
|
||||
@@ -634,10 +611,10 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the
|
||||
/// caller) already proposes the same change as `checkout`: authored by
|
||||
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a
|
||||
/// `c` tip tag matching the checkout's HEAD commit.
|
||||
/// Whether the pull request `pr` already proposes the same change as `checkout`.
|
||||
/// `pr` is a kind-1618 root, resolved `open` by the caller.
|
||||
/// Matches when authored by `user` with a matching `branch-name` tag.
|
||||
/// For renamed branches, a `c` tip tag matching the checkout's HEAD commit counts.
|
||||
pub fn pr_proposes_checkout(
|
||||
pr: &Event,
|
||||
open: bool,
|
||||
@@ -724,8 +701,8 @@ mod tests {
|
||||
repo_addr(owner(), id)
|
||||
}
|
||||
|
||||
/// Build one announcement by the fixed test owner with `clone` URLs and
|
||||
/// an EUC.
|
||||
/// Build one announcement by the fixed test owner.
|
||||
/// Takes `clone` URLs and an EUC.
|
||||
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
|
||||
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
|
||||
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
|
||||
@@ -807,8 +784,8 @@ mod tests {
|
||||
)];
|
||||
let base = addr("repo");
|
||||
|
||||
// The same path is both remembered and scanned (its origin matches);
|
||||
// the remembered occurrence wins and it is listed once.
|
||||
// The same path is both remembered and scanned, its origin matches.
|
||||
// The remembered occurrence wins and the path is listed once.
|
||||
let resolved = resolve_associations(
|
||||
&[remembered("/shared", "repo", 100)],
|
||||
&[
|
||||
@@ -848,7 +825,7 @@ mod tests {
|
||||
run(&["commit", "-m", message]);
|
||||
};
|
||||
|
||||
// A feature branch ahead of main: ready to contribute.
|
||||
// A feature branch ahead of main, ready to contribute.
|
||||
run(&["checkout", "-b", "feature"]);
|
||||
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
|
||||
commit("feature work");
|
||||
@@ -863,17 +840,17 @@ mod tests {
|
||||
assert!(checkout_status(&path, Some("main")).is_none());
|
||||
run(&["checkout", "--", "."]);
|
||||
|
||||
// Even with main: nothing to propose.
|
||||
// Even on main, nothing to propose.
|
||||
run(&["checkout", "main"]);
|
||||
assert_eq!(checkout_status(&path, Some("main")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_push_status_counts_unpushed_commits_only() {
|
||||
// The "grasp remote": a plain repository the checkout clones from
|
||||
// (origin URL = local path, so the whole cycle runs offline). Git
|
||||
// refuses pushes to its checked-out branch by default; act like a
|
||||
// grasp server and allow them.
|
||||
// The `grasp remote` is a plain repository the checkout clones from.
|
||||
// Its origin URL is a local path, so the whole cycle runs offline.
|
||||
// Git refuses pushes to a checked-out branch by default.
|
||||
// Act like a grasp server and allow them.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let remote = dir.path().join("remote");
|
||||
signed_git::init_repository(&remote, "My Repo", "").expect("init");
|
||||
@@ -913,7 +890,7 @@ mod tests {
|
||||
// A fresh clone has nothing to push.
|
||||
assert_eq!(checkout_push_status(&checkout), None);
|
||||
|
||||
// One local commit: ready to push, counted against the remote.
|
||||
// One local commit, ready to push, counted against the remote.
|
||||
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
||||
run(&["add", "-A"]);
|
||||
run(&["commit", "-m", "local work"]);
|
||||
@@ -923,12 +900,12 @@ mod tests {
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.head.len(), 40);
|
||||
|
||||
// After the push the same commit is on the remote: idle again.
|
||||
// After the push the same commit is on the remote, idle again.
|
||||
run(&["push", "origin", "main"]);
|
||||
assert_eq!(checkout_push_status(&checkout), None);
|
||||
|
||||
// A commit made by someone else on the remote must not count as
|
||||
// local work (it is behind, not ahead).
|
||||
// A commit made by someone else on the remote must not count as local work.
|
||||
// It is behind, not ahead.
|
||||
let remote_run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&remote)
|
||||
@@ -979,15 +956,15 @@ mod tests {
|
||||
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Without the branch name (renamed), the `c` tip still matches.
|
||||
// Without a branch-name tag, the `c` tip still matches for a renamed branch.
|
||||
let pr = pr_event(
|
||||
author,
|
||||
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
|
||||
);
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Someone else's PR, a closed PR, a different branch and a missing
|
||||
// tip all leave the checkout uncovered.
|
||||
// Someone else's PR, a closed PR, a different branch and a missing tip.
|
||||
// They all leave the checkout uncovered.
|
||||
let pr = pr_event(author, &[&["branch-name", "feature"]]);
|
||||
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
|
||||
let other = pr_event(
|
||||
|
||||
Reference in New Issue
Block a user