chore: refactor backend around domain types #26
@@ -8,10 +8,16 @@
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Surface backend errors as error notifications in the workspace instead of silently dropping them
|
||||||
|
- Break ties in repository activity lists by event id, so same-second events order deterministically
|
||||||
|
- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
## v0.2.0-alpha - 2026/09/27
|
## v0.2.0-alpha - 2026/09/27
|
||||||
|
|||||||
Generated
+1
-18
@@ -881,12 +881,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "btreecap"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "built"
|
name = "built"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -5741,17 +5735,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nostr-memory"
|
|
||||||
version = "0.45.2"
|
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
|
||||||
dependencies = [
|
|
||||||
"btreecap",
|
|
||||||
"nostr",
|
|
||||||
"nostr-database",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-sdk"
|
name = "nostr-sdk"
|
||||||
version = "0.45.4"
|
version = "0.45.4"
|
||||||
@@ -8151,6 +8134,7 @@ version = "0.2.0-alpha"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
"serde",
|
"serde",
|
||||||
|
"utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8178,7 +8162,6 @@ dependencies = [
|
|||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
"nostr-gossip-memory",
|
"nostr-gossip-memory",
|
||||||
"nostr-lmdb",
|
"nostr-lmdb",
|
||||||
"nostr-memory",
|
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ settings = { path = "crates/settings" }
|
|||||||
|
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
|||||||
/// On Windows, this is `%APPDATA%\Signed`.
|
/// On Windows, this is `%APPDATA%\Signed`.
|
||||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
pub fn home_dir() -> PathBuf {
|
fn home_dir() -> PathBuf {
|
||||||
dirs::home_dir().expect("failed to determine home directory")
|
dirs::home_dir().expect("failed to determine home directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ pub fn documents_dir() -> PathBuf {
|
|||||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config_dir() -> &'static PathBuf {
|
fn config_dir() -> &'static PathBuf {
|
||||||
CONFIG_DIR.get_or_init(|| {
|
CONFIG_DIR.get_or_init(|| {
|
||||||
if cfg!(target_os = "windows") {
|
if cfg!(target_os = "windows") {
|
||||||
dirs::config_dir()
|
dirs::config_dir()
|
||||||
@@ -60,7 +60,7 @@ pub fn config_dir() -> &'static PathBuf {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_dir() -> &'static PathBuf {
|
fn data_dir() -> &'static PathBuf {
|
||||||
CURRENT_DATA_DIR.get_or_init(|| {
|
CURRENT_DATA_DIR.get_or_init(|| {
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
home_dir()
|
home_dir()
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
utils = { path = "../utils" }
|
||||||
|
|||||||
@@ -1,23 +1,99 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
///
|
#[serde(transparent)]
|
||||||
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
|
pub struct RepoAddr(Coordinate);
|
||||||
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
|
|
||||||
pub type RepoAddr = Coordinate;
|
|
||||||
|
|
||||||
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
impl RepoAddr {
|
||||||
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
pub fn new(owner: PublicKey, identifier: impl Into<String>) -> Self {
|
||||||
|
Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identifier_from_name(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '/' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> Kind {
|
||||||
|
self.0.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
self.0.public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identifier(&self) -> &str {
|
||||||
|
&self.0.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coordinate(&self) -> &Coordinate {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn announcement_filter(&self) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GitRepoAnnouncement)
|
||||||
|
.author(self.public_key())
|
||||||
|
.identifier(self.identifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_filter(&self) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::RepoState)
|
||||||
|
.author(self.public_key())
|
||||||
|
.identifier(self.identifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statuses may omit the `a` tag per NIP-34; those are not matched here.
|
||||||
|
pub fn activity_filter(&self) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds(crate::filters::ACTIVITY_KINDS)
|
||||||
|
.coordinate(&self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deletion_filters(&self) -> Vec<Filter> {
|
||||||
|
vec![
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
|
.author(self.public_key()),
|
||||||
|
Filter::new().kind(Kind::EventDeletion).coordinate(&self.0),
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn identifier_from_name(name: &str) -> String {
|
impl fmt::Display for RepoAddr {
|
||||||
name.chars()
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
.map(|c| {
|
fmt::Display::fmt(&self.0, f)
|
||||||
if c.is_ascii_alphanumeric() || c == '/' {
|
}
|
||||||
c
|
}
|
||||||
} else {
|
|
||||||
'-'
|
impl FromStr for RepoAddr {
|
||||||
}
|
type Err = <Coordinate as FromStr>::Err;
|
||||||
})
|
|
||||||
.collect()
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Coordinate::from_str(s).map(RepoAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Coordinate> for RepoAddr {
|
||||||
|
fn from(coordinate: Coordinate) -> Self {
|
||||||
|
RepoAddr(coordinate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RepoAddr> for Coordinate {
|
||||||
|
fn from(addr: RepoAddr) -> Self {
|
||||||
|
addr.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,14 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
|
||||||
/// built from the kind-5 and kind-62 events in the local database.
|
|
||||||
///
|
|
||||||
/// Deleted events are hidden before they reach the UI.
|
|
||||||
///
|
|
||||||
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
|
||||||
pub struct Deletions {
|
pub struct Deletions {
|
||||||
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
|
||||||
ids: HashSet<(EventId, PublicKey)>,
|
ids: HashSet<(EventId, PublicKey)>,
|
||||||
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
// All versions of the addressable event up to `cutoff` are deleted.
|
||||||
///
|
|
||||||
/// All versions of the addressable event up to `cutoff` are deleted.
|
|
||||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||||
/// `(author, cutoff)` from kind-62 vanish requests.
|
|
||||||
vanished: Vec<(PublicKey, Timestamp)>,
|
vanished: Vec<(PublicKey, Timestamp)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deletions {
|
impl Deletions {
|
||||||
/// Build the deletion index from raw kind-5 and kind-62 events.
|
|
||||||
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
|
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
|
||||||
let mut ids = HashSet::new();
|
let mut ids = HashSet::new();
|
||||||
let mut coords = Vec::new();
|
let mut coords = Vec::new();
|
||||||
@@ -36,8 +25,8 @@ impl Deletions {
|
|||||||
.map(|c| (c, event.pubkey, event.created_at)),
|
.map(|c| (c, event.pubkey, event.created_at)),
|
||||||
);
|
);
|
||||||
} else if event.kind == Kind::RequestToVanish {
|
} else if event.kind == Kind::RequestToVanish {
|
||||||
// Client-side we can't verify which relay the request targeted.
|
// We can't verify which relay the request targeted, so any
|
||||||
// Any vanish request is then honored for the author's events.
|
// vanish request is honored for the author's events.
|
||||||
vanished.push((event.pubkey, event.created_at));
|
vanished.push((event.pubkey, event.created_at));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,10 +38,8 @@ impl Deletions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the event is covered by a valid deletion or vanish request.
|
// A request is valid when its author matches the deleted event's author,
|
||||||
/// A request is valid when its author matches the deleted event's author, per NIP-09.
|
// per NIP-09. Addressable events are deleted up to the request's `created_at`.
|
||||||
///
|
|
||||||
/// Addressable events are deleted up to the request's `created_at`.
|
|
||||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||||
if self
|
if self
|
||||||
.vanished
|
.vanished
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::RepoAddr;
|
|
||||||
|
|
||||||
/// Kinds that make up the activity of a repository.
|
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
Kind::Comment,
|
Kind::Comment,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -17,8 +14,7 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
|||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||||
pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPullRequest,
|
Kind::GitPullRequest,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -29,7 +25,6 @@ pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
|||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Git root kinds that make a comment count as git activity.
|
|
||||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -37,7 +32,6 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
|||||||
Kind::GitRepoAnnouncement,
|
Kind::GitRepoAnnouncement,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
|
||||||
pub fn is_repo_kind(kind: Kind) -> bool {
|
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||||
kind == Kind::GitRepoAnnouncement
|
kind == Kind::GitRepoAnnouncement
|
||||||
|| kind == Kind::RepoState
|
|| kind == Kind::RepoState
|
||||||
@@ -46,7 +40,6 @@ pub fn is_repo_kind(kind: Kind) -> bool {
|
|||||||
|| ACTIVITY_KINDS.contains(&kind)
|
|| ACTIVITY_KINDS.contains(&kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Value of the first tag named `name` on `event`.
|
|
||||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||||
event
|
event
|
||||||
.tags
|
.tags
|
||||||
@@ -55,175 +48,104 @@ fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
|||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kind named by the first tag `name` on `event`.
|
|
||||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||||
tag_value(event, name)?.parse::<Kind>().ok()
|
tag_value(event, name)?.parse::<Kind>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latest announcement event for a repository.
|
pub struct Filters;
|
||||||
pub fn announcement(addr: &RepoAddr) -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::GitRepoAnnouncement)
|
|
||||||
.author(addr.public_key)
|
|
||||||
.identifier(addr.identifier.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Latest state event for a repository, carrying refs and HEAD.
|
impl Filters {
|
||||||
pub fn state(addr: &RepoAddr) -> Filter {
|
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::RepoState)
|
|
||||||
.author(addr.public_key)
|
|
||||||
.identifier(addr.identifier.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
Filter::new()
|
||||||
/// The `a` tag is optional on status events per NIP-34.
|
.kinds([
|
||||||
/// Statuses published without it are not matched here.
|
Kind::GitStatusOpen,
|
||||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
Kind::GitStatusApplied,
|
||||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
Kind::GitStatusClosed,
|
||||||
}
|
Kind::GitStatusDraft,
|
||||||
|
])
|
||||||
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
.events(roots)
|
||||||
/// They are matched via the `#e` tag. One filter covers all roots.
|
|
||||||
///
|
|
||||||
/// A negentropy sync reconciles them in a single session, not one per root.
|
|
||||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kinds([
|
|
||||||
Kind::GitStatusOpen,
|
|
||||||
Kind::GitStatusApplied,
|
|
||||||
Kind::GitStatusClosed,
|
|
||||||
Kind::GitStatusDraft,
|
|
||||||
])
|
|
||||||
.events(roots)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A user's grasp list, kind `10317`.
|
|
||||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::GitUserGraspList)
|
|
||||||
.author(public_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
|
||||||
/// The roots are issues, patches and PRs.
|
|
||||||
///
|
|
||||||
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
|
||||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
|
||||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
|
||||||
if roots.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
}
|
||||||
vec![
|
|
||||||
|
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GitUserGraspList)
|
||||||
|
.author(public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two filters: combining `#E` and `#e` would AND the conditions.
|
||||||
|
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||||
|
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||||
|
if roots.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
vec![
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::Comment)
|
||||||
|
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::Comment)
|
||||||
|
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_comments(me: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::Comment)
|
.kind(Kind::Comment)
|
||||||
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
|
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||||
|
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Filter::pubkey` matches the git events' lowercase `p` tag.
|
||||||
|
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||||
|
vec![
|
||||||
|
Self::notification_comments(me),
|
||||||
|
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A comment on an unrelated kind matches too, so results must be filtered
|
||||||
|
// through `GitEvent::is_git_activity` before display.
|
||||||
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
|
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_announcements() -> Filter {
|
||||||
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_states() -> Filter {
|
||||||
|
Filter::new().kind(Kind::RepoState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantized to whole days so identical filters hash the same.
|
||||||
|
fn deletions_since() -> Timestamp {
|
||||||
|
let now = Timestamp::now().as_secs();
|
||||||
|
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletion requests must be known before any other event is shown.
|
||||||
|
pub fn deletions() -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::Comment)
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
.since(Self::deletions_since())
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-22 comments on our issues, patches and pull requests.
|
pub(crate) fn is_git_comment(event: &Event) -> bool {
|
||||||
/// They are matched via the uppercase `P` and `K` tags, not authorship.
|
|
||||||
pub fn notification_comments(me: PublicKey) -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::Comment)
|
|
||||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
|
||||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Activity directed at us: comments on our roots, and git events tagging us
|
|
||||||
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
|
||||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
|
||||||
vec![
|
|
||||||
notification_comments(me),
|
|
||||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Git activity authored by `me`, for "Continue where you left off".
|
|
||||||
///
|
|
||||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
|
||||||
/// through [`is_git_activity`] before display.
|
|
||||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
|
||||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
|
||||||
fn is_git_comment(event: &Event) -> bool {
|
|
||||||
event.kind == Kind::Comment
|
event.kind == Kind::Comment
|
||||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a status event references a git root, checked via its `k` tag.
|
pub(crate) fn is_git_status(event: &Event) -> bool {
|
||||||
fn is_git_status(event: &Event) -> bool {
|
|
||||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `event` is git activity worth showing in the activity list.
|
|
||||||
pub fn is_git_activity(event: &Event) -> bool {
|
|
||||||
match event.kind {
|
|
||||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
|
||||||
Kind::Comment => is_git_comment(event),
|
|
||||||
Kind::GitStatusOpen
|
|
||||||
| Kind::GitStatusApplied
|
|
||||||
| Kind::GitStatusClosed
|
|
||||||
| Kind::GitStatusDraft => is_git_status(event),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All repository announcements, for global discovery.
|
|
||||||
pub fn all_announcements() -> Filter {
|
|
||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All repository state events, carrying each repository's refs and last push time.
|
|
||||||
pub fn all_states() -> Filter {
|
|
||||||
Filter::new().kind(Kind::RepoState)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How far back deletion requests are fetched and stored.
|
|
||||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
|
||||||
|
|
||||||
/// `now` minus [`DELETIONS_LOOKBACK`].
|
|
||||||
/// Quantized to whole days so identical filters hash the same.
|
|
||||||
///
|
|
||||||
/// This lets the backend's sync dedup match identical filters.
|
|
||||||
fn deletions_since() -> Timestamp {
|
|
||||||
let now = Timestamp::now().as_secs();
|
|
||||||
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
|
||||||
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
|
||||||
///
|
|
||||||
/// Deletion requests must be known before any other event is shown.
|
|
||||||
pub fn deletions() -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
|
||||||
.since(deletions_since())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletion events relevant to a single repository.
|
|
||||||
///
|
|
||||||
/// Requests authored by the repository owner.
|
|
||||||
///
|
|
||||||
/// Requests addressed to the repository coordinate via its `#a` tag.
|
|
||||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
|
||||||
vec![
|
|
||||||
Filter::new()
|
|
||||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
|
||||||
.author(addr.public_key),
|
|
||||||
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::GitEvent;
|
||||||
|
|
||||||
fn keys(seed: u8) -> Keys {
|
fn keys(seed: u8) -> Keys {
|
||||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||||
@@ -252,14 +174,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
||||||
|
|
||||||
assert!(is_git_activity(&on_git));
|
assert!(on_git.is_git_activity());
|
||||||
assert!(is_git_activity(&on_repo));
|
assert!(on_repo.is_git_activity());
|
||||||
assert!(!is_git_activity(&on_note));
|
assert!(!on_note.is_git_activity());
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity());
|
||||||
&keys(1),
|
|
||||||
Kind::Comment,
|
|
||||||
Vec::new()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -275,12 +193,8 @@ mod tests {
|
|||||||
vec![kind_tag("k", Kind::Metadata)],
|
vec![kind_tag("k", Kind::Metadata)],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(is_git_activity(&status));
|
assert!(status.is_git_activity());
|
||||||
assert!(!is_git_activity(&unrelated));
|
assert!(!unrelated.is_git_activity());
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity());
|
||||||
&keys(1),
|
|
||||||
Kind::GitStatusClosed,
|
|
||||||
Vec::new()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-264
@@ -4,41 +4,29 @@ use std::time::Duration;
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{RepoAddr, activity_subject};
|
use crate::{GitEvent, RepoAddr};
|
||||||
|
|
||||||
/// Window before `now` that an advanced cutoff retreats to.
|
|
||||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||||
|
|
||||||
/// Window before `now` that a mark-all cutoff retreats to.
|
|
||||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||||
|
|
||||||
/// A thread of notification and own-activity events sharing one root.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InboxItem {
|
pub struct InboxItem {
|
||||||
/// The root issue, patch or pull request the events belong to.
|
|
||||||
pub root: EventId,
|
pub root: EventId,
|
||||||
/// The root event itself, when it is known locally.
|
|
||||||
pub root_event: Option<Event>,
|
pub root_event: Option<Event>,
|
||||||
/// Repository the root belongs to, from the root's `a` tag.
|
|
||||||
pub address: Option<RepoAddr>,
|
pub address: Option<RepoAddr>,
|
||||||
/// Notification events directed at the user, newest first.
|
|
||||||
pub events: Vec<Event>,
|
pub events: Vec<Event>,
|
||||||
/// The user's own events in the thread, newest first.
|
|
||||||
pub own_events: Vec<Event>,
|
pub own_events: Vec<Event>,
|
||||||
/// Unread event ids, oldest first.
|
|
||||||
pub unread_ids: Vec<EventId>,
|
pub unread_ids: Vec<EventId>,
|
||||||
/// Whether every notification event in the thread is archived.
|
|
||||||
pub archived: bool,
|
pub archived: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InboxItem {
|
impl InboxItem {
|
||||||
/// Title of the thread, read from its root issue/patch/PR when known.
|
|
||||||
pub fn title(&self) -> String {
|
pub fn title(&self) -> String {
|
||||||
self.root_event
|
self.root_event
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.or_else(|| self.own_events.first())
|
.or_else(|| self.own_events.first())
|
||||||
.or_else(|| self.events.first())
|
.or_else(|| self.events.first())
|
||||||
.map(activity_subject)
|
.map(|event| event.activity_subject())
|
||||||
.unwrap_or_else(|| "Untitled".to_string())
|
.unwrap_or_else(|| "Untitled".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +38,6 @@ impl InboxItem {
|
|||||||
.map(|event| event.kind)
|
.map(|event| event.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Timestamp of the newest event in the thread.
|
|
||||||
pub fn latest_activity(&self) -> Timestamp {
|
pub fn latest_activity(&self) -> Timestamp {
|
||||||
self.root_event
|
self.root_event
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -62,7 +49,6 @@ impl InboxItem {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Up to `limit` events of the thread, oldest first.
|
|
||||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||||
let mut seen: HashSet<EventId> = HashSet::new();
|
let mut seen: HashSet<EventId> = HashSet::new();
|
||||||
let mut events: Vec<Event> = Vec::new();
|
let mut events: Vec<Event> = Vec::new();
|
||||||
@@ -92,7 +78,6 @@ impl InboxItem {
|
|||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the thread has an unread event still visible in the inbox.
|
|
||||||
pub fn is_unread(&self) -> bool {
|
pub fn is_unread(&self) -> bool {
|
||||||
!self.archived && !self.unread_ids.is_empty()
|
!self.archived && !self.unread_ids.is_empty()
|
||||||
}
|
}
|
||||||
@@ -112,44 +97,144 @@ impl InboxItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Root issue, patch or pull request of a notification event.
|
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||||
///
|
lookup: &'a L,
|
||||||
/// Returns `None` when the event is not git-related, or when its root is a
|
}
|
||||||
/// coordinate rather than an event.
|
|
||||||
///
|
impl<'a, L> ThreadResolver<'a, L>
|
||||||
/// - issue (1621) / PR (1618): itself
|
|
||||||
/// - patch (1617): its `e` parent patch, else itself
|
|
||||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
|
||||||
/// - PR update (1619): uppercase `E`
|
|
||||||
/// - statuses (1630-1633): NIP-10 root `e`
|
|
||||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
|
||||||
where
|
where
|
||||||
L: Fn(EventId) -> Option<Event>,
|
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||||
{
|
{
|
||||||
match event.kind {
|
pub fn new(lookup: &'a L) -> Self {
|
||||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
Self { lookup }
|
||||||
Kind::GitPatch => Some(match first_e_id(event) {
|
}
|
||||||
Some(parent) => resolve_thread_root(parent, lookup),
|
|
||||||
None => event.id,
|
// Kind → root mapping:
|
||||||
}),
|
// - issue (1621) / PR (1618): itself
|
||||||
Kind::Comment => match nip22::extract_root(event) {
|
// - patch (1617): its `e` parent patch, else itself
|
||||||
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
|
// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||||
|
// - PR update (1619): uppercase `E`
|
||||||
|
// - statuses (1630-1633): NIP-10 root `e`
|
||||||
|
// Returns `None` when the event is not git-related, or when its root is a
|
||||||
|
// coordinate rather than an event.
|
||||||
|
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||||
|
match event.kind {
|
||||||
|
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||||
|
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||||
|
Some(parent) => self.resolve_thread_root(parent),
|
||||||
|
None => event.id,
|
||||||
|
}),
|
||||||
|
Kind::Comment => match nip22::extract_root(event) {
|
||||||
|
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Kind::GitPullRequestUpdate => self
|
||||||
|
.first_uppercase_e_id(event)
|
||||||
|
.map(|root| self.resolve_thread_root(root)),
|
||||||
|
Kind::GitStatusOpen
|
||||||
|
| Kind::GitStatusApplied
|
||||||
|
| Kind::GitStatusClosed
|
||||||
|
| Kind::GitStatusDraft => self
|
||||||
|
.nip10_root_id(event)
|
||||||
|
.map(|root| self.resolve_thread_root(root)),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
|
||||||
Kind::GitPullRequestUpdate => {
|
|
||||||
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
|
|
||||||
}
|
}
|
||||||
Kind::GitStatusOpen
|
}
|
||||||
| Kind::GitStatusApplied
|
|
||||||
| Kind::GitStatusClosed
|
// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||||
| Kind::GitStatusDraft => {
|
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||||
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
|
let mut seen = HashSet::new();
|
||||||
|
let mut root = id;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !seen.insert(root) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(event) = (self.lookup)(root) else {
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.parent_id(&event) {
|
||||||
|
Some(parent) => root = parent,
|
||||||
|
None => return root,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
}
|
||||||
|
|
||||||
|
// Mirrors gitworkshop's `getParentId`.
|
||||||
|
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
for marker in ["reply", "root"] {
|
||||||
|
if let Some(id) = event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| self.e_tag_with_marker(tag, marker))
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||||
|
if tag.kind() != "e" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||||
|
|
||||||
|
if is_mention {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
}) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.first_uppercase_e_id(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
||||||
|
.or_else(|| self.first_e_id(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
self.first_tag_id(event, "e")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
self.first_tag_id(event, "E")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
|
||||||
|
event.tags.iter().find_map(|tag| {
|
||||||
|
if tag.kind() != name {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option<EventId> {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group notification events and the user's own events into one item per thread.
|
|
||||||
pub fn group<E, O, L>(
|
pub fn group<E, O, L>(
|
||||||
events: E,
|
events: E,
|
||||||
own: O,
|
own: O,
|
||||||
@@ -162,12 +247,13 @@ where
|
|||||||
O: IntoIterator<Item = Event>,
|
O: IntoIterator<Item = Event>,
|
||||||
L: Fn(EventId) -> Option<Event>,
|
L: Fn(EventId) -> Option<Event>,
|
||||||
{
|
{
|
||||||
|
let resolver = ThreadResolver::new(lookup);
|
||||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.pubkey == me {
|
if event.pubkey == me {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(root) = notification_root(&event, lookup) else {
|
let Some(root) = resolver.notification_root(&event) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
groups.entry(root).or_default().push(event);
|
groups.entry(root).or_default().push(event);
|
||||||
@@ -175,7 +261,7 @@ where
|
|||||||
|
|
||||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||||
for event in own {
|
for event in own {
|
||||||
let root = notification_root(&event, lookup).unwrap_or(event.id);
|
let root = resolver.notification_root(&event).unwrap_or(event.id);
|
||||||
own_groups.entry(root).or_default().push(event);
|
own_groups.entry(root).or_default().push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,8 +274,8 @@ where
|
|||||||
.map(|root| {
|
.map(|root| {
|
||||||
let mut events = groups.remove(&root).unwrap_or_default();
|
let mut events = groups.remove(&root).unwrap_or_default();
|
||||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||||
sort_newest_first(&mut events);
|
utils::sort_newest_first(&mut events);
|
||||||
sort_newest_first(&mut own_events);
|
utils::sort_newest_first(&mut own_events);
|
||||||
|
|
||||||
let root_event = lookup(root);
|
let root_event = lookup(root);
|
||||||
|
|
||||||
@@ -197,7 +283,8 @@ where
|
|||||||
root,
|
root,
|
||||||
address: root_event
|
address: root_event
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|event| event.tags.coordinates().next()),
|
.and_then(|event| event.tags.coordinates().next())
|
||||||
|
.map(RepoAddr::from),
|
||||||
root_event,
|
root_event,
|
||||||
events,
|
events,
|
||||||
own_events,
|
own_events,
|
||||||
@@ -218,16 +305,8 @@ where
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort thread events newest first, ties broken by id.
|
// High-water-mark model: events at or before the cutoff
|
||||||
fn sort_newest_first(events: &mut [Event]) {
|
// are covered without an entry in the id set.
|
||||||
events.sort_by(|a, b| {
|
|
||||||
b.created_at
|
|
||||||
.cmp(&a.created_at)
|
|
||||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read and archive state of the inbox, a high-water-mark model.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct InboxReadState {
|
pub struct InboxReadState {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -241,31 +320,26 @@ pub struct InboxReadState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InboxReadState {
|
impl InboxReadState {
|
||||||
/// Whether `event` is at or before the read cutoff, or marked read.
|
|
||||||
pub fn is_read(&self, event: &Event) -> bool {
|
pub fn is_read(&self, event: &Event) -> bool {
|
||||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `event` is at or before the archived cutoff, or marked archived.
|
|
||||||
pub fn is_archived(&self, event: &Event) -> bool {
|
pub fn is_archived(&self, event: &Event) -> bool {
|
||||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark one event read. Events at or before the cutoff are already read.
|
|
||||||
pub fn mark_read(&mut self, event: &Event) {
|
pub fn mark_read(&mut self, event: &Event) {
|
||||||
if event.created_at > self.read_before {
|
if event.created_at > self.read_before {
|
||||||
self.read_ids.insert(event.id);
|
self.read_ids.insert(event.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark one event archived. Events at or before the cutoff are already archived.
|
|
||||||
pub fn mark_archived(&mut self, event: &Event) {
|
pub fn mark_archived(&mut self, event: &Event) {
|
||||||
if event.created_at > self.archived_before {
|
if event.created_at > self.archived_before {
|
||||||
self.archived_ids.insert(event.id);
|
self.archived_ids.insert(event.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
|
||||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||||
let cutoff = now - MARK_ALL_WINDOW;
|
let cutoff = now - MARK_ALL_WINDOW;
|
||||||
self.read_before = cutoff;
|
self.read_before = cutoff;
|
||||||
@@ -276,15 +350,14 @@ impl InboxReadState {
|
|||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance the read cutoff to the newest point that keeps unread events
|
// Advance the cutoff to the newest point that keeps unread events unread,
|
||||||
/// unread, then prune the id set.
|
// then prune the id set.
|
||||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||||
self.read_before = cutoff;
|
self.read_before = cutoff;
|
||||||
prune_ids(&mut self.read_ids, all, cutoff);
|
prune_ids(&mut self.read_ids, all, cutoff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
|
|
||||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||||
self.is_archived(event)
|
self.is_archived(event)
|
||||||
@@ -294,7 +367,6 @@ impl InboxReadState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
|
|
||||||
fn advance_cutoff<M>(
|
fn advance_cutoff<M>(
|
||||||
all: &[Event],
|
all: &[Event],
|
||||||
me: PublicKey,
|
me: PublicKey,
|
||||||
@@ -321,7 +393,6 @@ where
|
|||||||
candidate.max(current)
|
candidate.max(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop ids whose event is unknown or now covered by the cutoff.
|
|
||||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||||
let created_at: HashMap<EventId, Timestamp> = all
|
let created_at: HashMap<EventId, Timestamp> = all
|
||||||
.iter()
|
.iter()
|
||||||
@@ -330,103 +401,6 @@ fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
|||||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
|
||||||
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
let mut root = id;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !seen.insert(root) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(event) = lookup(root) else {
|
|
||||||
return root;
|
|
||||||
};
|
|
||||||
|
|
||||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
match parent_id(&event) {
|
|
||||||
Some(parent) => root = parent,
|
|
||||||
None => return root,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
|
|
||||||
fn parent_id(event: &Event) -> Option<EventId> {
|
|
||||||
for marker in ["reply", "root"] {
|
|
||||||
if let Some(id) = event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| e_tag_with_marker(tag, marker))
|
|
||||||
{
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
|
||||||
if tag.kind() != "e" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let slice = tag.as_slice();
|
|
||||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
|
||||||
|
|
||||||
if is_mention {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
}) {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
first_uppercase_e_id(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
|
|
||||||
fn nip10_root_id(event: &Event) -> Option<EventId> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| e_tag_with_marker(tag, "root"))
|
|
||||||
.or_else(|| first_e_id(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First `e` tag id, in document order.
|
|
||||||
fn first_e_id(event: &Event) -> Option<EventId> {
|
|
||||||
first_tag_id(event, "e")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First uppercase `E` tag id, in document order.
|
|
||||||
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
|
|
||||||
first_tag_id(event, "E")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
|
|
||||||
event.tags.iter().find_map(|tag| {
|
|
||||||
if tag.kind() != name {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event id from a four-element `e` tag carrying `marker`.
|
|
||||||
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
|
|
||||||
let slice = tag.as_slice();
|
|
||||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -489,7 +463,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let events = [issue.clone(), comment.clone()];
|
let events = [issue.clone(), comment.clone()];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
notification_root(&comment, &lookup(&events)),
|
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
||||||
Some(issue.id)
|
Some(issue.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -500,7 +474,7 @@ mod tests {
|
|||||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||||
let events = [root_patch.clone(), child_patch.clone()];
|
let events = [root_patch.clone(), child_patch.clone()];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
notification_root(&child_patch, &lookup(&events)),
|
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
||||||
Some(root_patch.id)
|
Some(root_patch.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -515,7 +489,10 @@ mod tests {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
let events = [issue.clone(), status.clone()];
|
let events = [issue.clone(), status.clone()];
|
||||||
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
|
assert_eq!(
|
||||||
|
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
||||||
|
Some(issue.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -524,7 +501,10 @@ mod tests {
|
|||||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||||
let events = [issue.clone(), reply, nested.clone()];
|
let events = [issue.clone(), reply, nested.clone()];
|
||||||
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
|
assert_eq!(
|
||||||
|
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
||||||
|
Some(issue.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -568,7 +548,6 @@ mod tests {
|
|||||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||||
assert_eq!(items[0].title(), "Add retry logic");
|
assert_eq!(items[0].title(), "Add retry logic");
|
||||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||||
// The own events are kept apart from the notifications, newest first.
|
|
||||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
items[0]
|
items[0]
|
||||||
@@ -579,94 +558,4 @@ mod tests {
|
|||||||
vec![issue.id, mine.id, reply.id]
|
vec![issue.id, mine.id, reply.id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mark_all_read_marks_known_recent_events() {
|
|
||||||
let me = keys(1);
|
|
||||||
let now = Timestamp::from_secs(1_000_000_000);
|
|
||||||
let recent = issue(&keys(2), now.as_secs() - 1000);
|
|
||||||
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
|
|
||||||
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
|
|
||||||
let mine = issue(&keys(1), now.as_secs() - 100);
|
|
||||||
|
|
||||||
let mut state = InboxReadState::default();
|
|
||||||
state.mark_all_read(
|
|
||||||
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
|
|
||||||
me.public_key(),
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
|
|
||||||
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
|
|
||||||
assert!(state.is_read(&recent));
|
|
||||||
assert!(state.is_read(&ancient));
|
|
||||||
assert!(!state.is_read(&mine));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn advance_read_never_moves_the_cutoff_backwards() {
|
|
||||||
let me = keys(1);
|
|
||||||
let unread = issue(&keys(2), 1_000);
|
|
||||||
let all = [unread];
|
|
||||||
let now = Timestamp::from_secs(1_000_000_000);
|
|
||||||
|
|
||||||
let mut state = InboxReadState {
|
|
||||||
read_before: Timestamp::from_secs(999_999_999),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
state.advance_read(&all, me.public_key(), now);
|
|
||||||
|
|
||||||
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
|
|
||||||
let me = keys(1);
|
|
||||||
let now = Timestamp::from_secs(1_000_000_000);
|
|
||||||
let five_days = 5 * 24 * 60 * 60;
|
|
||||||
let old_unread = issue(&keys(2), now.as_secs() - five_days);
|
|
||||||
// Read ids that fall before and after the new cutoff.
|
|
||||||
let stale = signed(
|
|
||||||
&keys(2),
|
|
||||||
Kind::GitIssue,
|
|
||||||
Vec::new(),
|
|
||||||
now.as_secs() - five_days - 1000,
|
|
||||||
);
|
|
||||||
let fresh = signed(
|
|
||||||
&keys(2),
|
|
||||||
Kind::GitIssue,
|
|
||||||
Vec::new(),
|
|
||||||
now.as_secs() - 100_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut state = InboxReadState {
|
|
||||||
read_ids: HashSet::from([stale.id, fresh.id]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
state.advance_read(
|
|
||||||
&[old_unread.clone(), stale.clone(), fresh.clone()],
|
|
||||||
me.public_key(),
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(state.read_before, old_unread.created_at - 1);
|
|
||||||
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mark_archived_skips_events_at_or_before_the_cutoff() {
|
|
||||||
let now = Timestamp::from_secs(1_000_000_000);
|
|
||||||
let event = issue(&keys(2), now.as_secs() - 1000);
|
|
||||||
|
|
||||||
let mut state = InboxReadState {
|
|
||||||
archived_before: now,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
state.mark_archived(&event);
|
|
||||||
assert!(state.archived_ids.is_empty());
|
|
||||||
|
|
||||||
let mut state = InboxReadState::default();
|
|
||||||
state.mark_archived(&event);
|
|
||||||
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,10 @@ pub mod model;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
pub use addr::RepoAddr;
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use filters::{
|
pub use filters::Filters;
|
||||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
|
||||||
};
|
pub use model::{Announcement, GitEvent, PullRequest};
|
||||||
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
|
pub use state::RepoState;
|
||||||
pub use model::{
|
pub use status::RepoStatus;
|
||||||
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
|
||||||
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
|
||||||
};
|
|
||||||
pub use state::{build_state, parse_state};
|
|
||||||
pub use status::{RepoStatus, references_root, resolve_status};
|
|
||||||
|
|||||||
+263
-261
@@ -2,44 +2,30 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::{RepoAddr, repo_addr};
|
use crate::RepoAddr;
|
||||||
|
|
||||||
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
/// ID of the announcement event itself.
|
|
||||||
pub event_id: EventId,
|
pub event_id: EventId,
|
||||||
/// Repository ID, the `d` tag.
|
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// Author of the announcement event.
|
|
||||||
pub owner: PublicKey,
|
pub owner: PublicKey,
|
||||||
/// When the announcement was published, used for latest-wins resolution.
|
|
||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// Webpage URLs for browsing.
|
|
||||||
pub web: Vec<Url>,
|
pub web: Vec<Url>,
|
||||||
/// URLs for `git clone`.
|
|
||||||
pub clone: Vec<Url>,
|
pub clone: Vec<Url>,
|
||||||
/// Relays the repository monitors for patches and issues.
|
|
||||||
pub relays: Vec<RelayUrl>,
|
pub relays: Vec<RelayUrl>,
|
||||||
/// Earliest unique commit ID, the `r` tag with `euc` marker.
|
|
||||||
pub euc: Option<String>,
|
pub euc: Option<String>,
|
||||||
/// Other recognized maintainers.
|
|
||||||
pub maintainers: Vec<PublicKey>,
|
pub maintainers: Vec<PublicKey>,
|
||||||
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
|
|
||||||
pub upstream: Option<Upstream>,
|
pub upstream: Option<Upstream>,
|
||||||
/// Hashtags labelling the repository, the `t` tags.
|
|
||||||
pub hashtags: Vec<String>,
|
pub hashtags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `u` tag of a fork announcement, per NIP-34.
|
// The `u` tag of a fork announcement, per NIP-34.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Upstream {
|
pub struct Upstream {
|
||||||
/// Raw first value of the `u` tag, a coordinate or git URL.
|
|
||||||
pub raw: String,
|
pub raw: String,
|
||||||
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
|
// `None` for the git-URL form.
|
||||||
/// `None` for the git-URL form.
|
|
||||||
pub addr: Option<RepoAddr>,
|
pub addr: Option<RepoAddr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +35,8 @@ impl Upstream {
|
|||||||
let addr = coordinate
|
let addr = coordinate
|
||||||
.parse::<Coordinate>()
|
.parse::<Coordinate>()
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
|
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
||||||
|
.map(RepoAddr::from);
|
||||||
Self {
|
Self {
|
||||||
raw: raw.to_owned(),
|
raw: raw.to_owned(),
|
||||||
addr,
|
addr,
|
||||||
@@ -64,216 +51,266 @@ impl Upstream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subject of a NIP-34 issue or pull request event.
|
pub trait GitEvent {
|
||||||
/// Taken from the `subject` tag, else the first non-empty line of the content.
|
fn activity_subject(&self) -> String;
|
||||||
pub fn activity_subject(event: &Event) -> String {
|
|
||||||
let subject = event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
|
|
||||||
subject
|
fn current_commit(&self) -> Option<String>;
|
||||||
.or_else(|| {
|
|
||||||
event
|
fn merge_base(&self) -> Option<String>;
|
||||||
.content
|
|
||||||
.lines()
|
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||||
.map(str::trim)
|
|
||||||
.find(|line| !line.is_empty())
|
fn branch_name(&self) -> Option<String>;
|
||||||
.map(|value| value.to_string())
|
|
||||||
})
|
fn is_git_activity(&self) -> bool;
|
||||||
.unwrap_or("Untitled".to_string())
|
|
||||||
|
// Matches both NIP-10 lowercase `e` and NIP-22 uppercase `E` root pointers.
|
||||||
|
fn references_root(&self, root: &EventId) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The patch set of a pull request.
|
impl GitEvent for Event {
|
||||||
///
|
fn activity_subject(&self) -> String {
|
||||||
/// Returns an empty list when no patch event can be linked to the PR.
|
let subject = self
|
||||||
pub fn pull_request_patches<'a>(
|
.tags
|
||||||
pr: &Event,
|
.iter()
|
||||||
patches: impl IntoIterator<Item = &'a Event>,
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
) -> Vec<&'a Event> {
|
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
subject
|
||||||
|
.or_else(|| {
|
||||||
|
self.content
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or("Untitled".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_commit(&self) -> Option<String> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_base(&self) -> Option<String> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_name(&self) -> Option<String> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_git_activity(&self) -> bool {
|
||||||
|
match self.kind {
|
||||||
|
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||||
|
Kind::Comment => crate::filters::is_git_comment(self),
|
||||||
|
Kind::GitStatusOpen
|
||||||
|
| Kind::GitStatusApplied
|
||||||
|
| Kind::GitStatusClosed
|
||||||
|
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn references_root(&self, root: &EventId) -> bool {
|
||||||
|
let root = root.to_hex();
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
||||||
|
fn activity_subject(&self) -> String {
|
||||||
|
(*self).activity_subject()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_commit(&self) -> Option<String> {
|
||||||
|
(*self).current_commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_base(&self) -> Option<String> {
|
||||||
|
(*self).merge_base()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||||
|
(*self).clone_urls()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_name(&self) -> Option<String> {
|
||||||
|
(*self).branch_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_git_activity(&self) -> bool {
|
||||||
|
(*self).is_git_activity()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn references_root(&self, root: &EventId) -> bool {
|
||||||
|
(*self).references_root(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PullRequest<'a>(pub &'a Event);
|
||||||
|
|
||||||
|
impl<'a> PullRequest<'a> {
|
||||||
|
pub fn new(event: &'a Event) -> Self {
|
||||||
|
Self(event)
|
||||||
|
}
|
||||||
|
|
||||||
// The PR references its root patch via an `e` tag.
|
// The PR references its root patch via an `e` tag.
|
||||||
// Follow the NIP-10 reply chain forward from there.
|
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
||||||
// Each patch replies to the previous one, and among several replies the newest wins.
|
let pr = self.0;
|
||||||
if let Some(root_id) = pr.tags.event_ids().next()
|
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
|
||||||
{
|
|
||||||
return forward_series(root, &patches);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The PR has no `e` tag.
|
if let Some(root_id) = pr.tags.event_ids().next()
|
||||||
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
|
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||||
// Walk the reply chain backward to the root.
|
{
|
||||||
let Some(tip) = current_commit_of(pr) else {
|
return Self::forward_series(root, &patches);
|
||||||
return Vec::new();
|
}
|
||||||
};
|
|
||||||
let Some(last) = patches
|
|
||||||
.iter()
|
|
||||||
.filter(|patch| patch_produces_commit(patch, &tip))
|
|
||||||
.max_by_key(|patch| patch.created_at)
|
|
||||||
.copied()
|
|
||||||
else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut series = vec![last];
|
let Some(tip) = pr.current_commit() else {
|
||||||
loop {
|
return Vec::new();
|
||||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
|
||||||
break;
|
|
||||||
};
|
};
|
||||||
let Some(prev) = patches
|
let Some(last) = patches
|
||||||
.iter()
|
.iter()
|
||||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||||
|
.max_by_key(|patch| patch.created_at)
|
||||||
.copied()
|
.copied()
|
||||||
else {
|
else {
|
||||||
break;
|
return Vec::new();
|
||||||
};
|
};
|
||||||
series.push(prev);
|
|
||||||
}
|
|
||||||
series.reverse();
|
|
||||||
series
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
let mut series = vec![last];
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
loop {
|
||||||
let series = pull_request_patches(pr, patches.iter().copied());
|
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||||
if series.is_empty() {
|
break;
|
||||||
return pr.content.clone();
|
};
|
||||||
}
|
let Some(prev) = patches
|
||||||
series
|
|
||||||
.iter()
|
|
||||||
.map(|patch| patch.content.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
|
||||||
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
|
||||||
let mut series = vec![root];
|
|
||||||
loop {
|
|
||||||
let next = patches
|
|
||||||
.iter()
|
|
||||||
.filter(|patch| !series.contains(patch))
|
|
||||||
.filter(|patch| {
|
|
||||||
patch
|
|
||||||
.tags
|
|
||||||
.event_ids()
|
|
||||||
.any(|id| id == series.last().unwrap().id)
|
|
||||||
})
|
|
||||||
.max_by_key(|patch| patch.created_at);
|
|
||||||
let Some(next) = next else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
series.push(next);
|
|
||||||
}
|
|
||||||
series
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
|
||||||
pub fn current_commit_of(event: &Event) -> Option<String> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `merge-base` tag of an event, the base commit a pull request diffs against.
|
|
||||||
pub fn merge_base_of(event: &Event) -> Option<String> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `clone` tag of an event, URLs the tip commit can be fetched from.
|
|
||||||
pub fn clone_urls_of(event: &Event) -> Option<Vec<Url>> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `branch-name` tag of an event, the proposed branch's name.
|
|
||||||
pub fn branch_name_of(event: &Event) -> Option<String> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author.
|
|
||||||
///
|
|
||||||
/// A pull request's tip is only mutable by its author, per NIP-34; updates
|
|
||||||
/// from anyone else are ignored even if they are newer.
|
|
||||||
pub fn latest_update<'a>(
|
|
||||||
events: impl Iterator<Item = &'a Event>,
|
|
||||||
root: &Event,
|
|
||||||
) -> Option<&'a Event> {
|
|
||||||
let root_hex = root.id.to_hex();
|
|
||||||
events
|
|
||||||
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
|
||||||
.filter(|e| e.pubkey == root.pubkey)
|
|
||||||
.filter(|e| {
|
|
||||||
e.tags
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||||
})
|
.copied()
|
||||||
.max_by_key(|e| e.created_at)
|
else {
|
||||||
}
|
break;
|
||||||
|
};
|
||||||
/// The announced forks of `base` a new pull request compare can be built from.
|
series.push(prev);
|
||||||
///
|
|
||||||
/// The user's own forks are listed first.
|
|
||||||
pub fn fork_candidates<'a>(
|
|
||||||
announcements: &'a [Announcement],
|
|
||||||
base: &RepoAddr,
|
|
||||||
base_euc: Option<&str>,
|
|
||||||
user: Option<PublicKey>,
|
|
||||||
) -> Vec<&'a Announcement> {
|
|
||||||
let (mut own, mut others) = (Vec::new(), Vec::new());
|
|
||||||
for announcement in announcements {
|
|
||||||
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if Some(announcement.owner) == user {
|
|
||||||
own.push(announcement);
|
|
||||||
} else {
|
|
||||||
others.push(announcement);
|
|
||||||
}
|
}
|
||||||
|
series.reverse();
|
||||||
|
series
|
||||||
}
|
}
|
||||||
own.into_iter().chain(others).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
// Falls back to the root event's content when no patch set is found.
|
||||||
///
|
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||||
/// It lets clients find existing patches for a specific commit.
|
let pr = self.0;
|
||||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
patch
|
let series = self.patches(patches.iter().copied());
|
||||||
.tags
|
if series.is_empty() {
|
||||||
.iter()
|
return pr.content.clone();
|
||||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
}
|
||||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
series
|
||||||
_ => false,
|
.iter()
|
||||||
})
|
.map(|patch| patch.content.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pull request's tip is only mutable by its author per NIP-34,
|
||||||
|
// updates from anyone else are ignored even if they are newer.
|
||||||
|
pub fn latest_update(
|
||||||
|
events: impl Iterator<Item = &'a Event>,
|
||||||
|
root: &Event,
|
||||||
|
) -> Option<&'a Event> {
|
||||||
|
let root_hex = root.id.to_hex();
|
||||||
|
events
|
||||||
|
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
||||||
|
.filter(|e| e.pubkey == root.pubkey)
|
||||||
|
.filter(|e| {
|
||||||
|
e.tags
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
||||||
|
})
|
||||||
|
.max_by_key(|e| e.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||||
|
let mut series = vec![root];
|
||||||
|
loop {
|
||||||
|
let next = patches
|
||||||
|
.iter()
|
||||||
|
.filter(|patch| !series.contains(patch))
|
||||||
|
.filter(|patch| {
|
||||||
|
patch
|
||||||
|
.tags
|
||||||
|
.event_ids()
|
||||||
|
.any(|id| id == series.last().unwrap().id)
|
||||||
|
})
|
||||||
|
.max_by_key(|patch| patch.created_at);
|
||||||
|
let Some(next) = next else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
series.push(next);
|
||||||
|
}
|
||||||
|
series
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets clients find existing patches for a specific commit.
|
||||||
|
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||||
|
patch
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Announcement {
|
impl Announcement {
|
||||||
|
// The user's own forks are listed first.
|
||||||
|
pub fn forks_in<'a>(
|
||||||
|
announcements: &'a [Announcement],
|
||||||
|
base: &RepoAddr,
|
||||||
|
base_euc: Option<&str>,
|
||||||
|
user: Option<PublicKey>,
|
||||||
|
) -> Vec<&'a Announcement> {
|
||||||
|
let (mut own, mut others) = (Vec::new(), Vec::new());
|
||||||
|
for announcement in announcements {
|
||||||
|
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if Some(announcement.owner) == user {
|
||||||
|
own.push(announcement);
|
||||||
|
} else {
|
||||||
|
others.push(announcement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
own.into_iter().chain(others).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a kind `30617` event.
|
/// Parse a kind `30617` event.
|
||||||
///
|
///
|
||||||
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||||
@@ -337,18 +374,14 @@ impl Announcement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn addr(&self) -> RepoAddr {
|
pub fn addr(&self) -> RepoAddr {
|
||||||
repo_addr(self.owner, self.id.clone())
|
RepoAddr::new(self.owner, self.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name of the repository, or a default if none is provided.
|
|
||||||
pub fn name(&self) -> String {
|
pub fn name(&self) -> String {
|
||||||
self.name.clone().unwrap_or("Untitled".into())
|
self.name.clone().unwrap_or("Untitled".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this announcement is a fork of the repository at `base`.
|
// The `u` tag pointing at `base` also covers permanent forks whose EUC diverged.
|
||||||
/// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged.
|
|
||||||
///
|
|
||||||
/// Or it shares `base`'s earliest unique commit and is not the base itself.
|
|
||||||
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||||
if self.addr() == *base {
|
if self.addr() == *base {
|
||||||
return false;
|
return false;
|
||||||
@@ -359,17 +392,14 @@ impl Announcement {
|
|||||||
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The description of the repository, or a default if none is provided.
|
|
||||||
pub fn description(&self) -> String {
|
pub fn description(&self) -> String {
|
||||||
self.description
|
self.description
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or("No description".to_string())
|
.unwrap_or("No description".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective maintainers of this repository,
|
// A `u` tag marking the repository as a subordinate fork excludes the
|
||||||
/// the announced `maintainers` plus the announcement author.
|
// announcement author from the maintainers, per NIP-34.
|
||||||
///
|
|
||||||
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
|
|
||||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||||
let mut maintainers = self.maintainers.clone();
|
let mut maintainers = self.maintainers.clone();
|
||||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||||
@@ -378,7 +408,6 @@ impl Announcement {
|
|||||||
maintainers
|
maintainers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `git clone` URLs for this repository, deduplicated.
|
|
||||||
pub fn clone_urls(&self) -> Vec<String> {
|
pub fn clone_urls(&self) -> Vec<String> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
self.clone
|
self.clone
|
||||||
@@ -472,7 +501,6 @@ mod tests {
|
|||||||
|
|
||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
// An invalid URL keeps the whole clone tag from being parsed.
|
|
||||||
assert!(announcement.clone.is_empty());
|
assert!(announcement.clone.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
announcement.relays,
|
announcement.relays,
|
||||||
@@ -495,11 +523,9 @@ mod tests {
|
|||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
let upstream = announcement.upstream.expect("parses the u tag");
|
let upstream = announcement.upstream.expect("parses the u tag");
|
||||||
|
|
||||||
// The coordinate part resolves to a repository address.
|
|
||||||
// The raw value keeps the `|git-url` suffix.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
upstream.addr,
|
upstream.addr,
|
||||||
Some(crate::repo_addr(
|
Some(RepoAddr::new(
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
"upstream"
|
"upstream"
|
||||||
))
|
))
|
||||||
@@ -516,34 +542,27 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||||
// The base repository, announced by the `u` tag's owner.
|
let base = RepoAddr::new(
|
||||||
let base = crate::repo_addr(
|
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
"upstream",
|
"upstream",
|
||||||
);
|
);
|
||||||
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
||||||
let fork = Announcement::from_event(&event).expect("parses");
|
let fork = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
// A `u` tag pointing at the base address marks a fork.
|
|
||||||
// This holds even when neither side announces an EUC.
|
|
||||||
assert!(fork.is_fork_of(&base, None));
|
assert!(fork.is_fork_of(&base, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_fork_of_matches_a_shared_euc() {
|
fn is_fork_of_matches_a_shared_euc() {
|
||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
// The base repo has no `u` tag. It announces the family EUC.
|
|
||||||
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||||
let base = Announcement::from_event(&base_event).expect("parses");
|
let base = Announcement::from_event(&base_event).expect("parses");
|
||||||
let base_addr = base.addr();
|
let base_addr = base.addr();
|
||||||
|
|
||||||
// A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC.
|
|
||||||
// Clients of the family can then find it.
|
|
||||||
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
||||||
let fork = Announcement::from_event(&fork_event).expect("parses");
|
let fork = Announcement::from_event(&fork_event).expect("parses");
|
||||||
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||||
|
|
||||||
// An unrelated repository with a different EUC is not a fork.
|
|
||||||
let other_event = announcement_event(&[
|
let other_event = announcement_event(&[
|
||||||
&["d", "other"],
|
&["d", "other"],
|
||||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||||
@@ -551,15 +570,12 @@ mod tests {
|
|||||||
let other = Announcement::from_event(&other_event).expect("parses");
|
let other = Announcement::from_event(&other_event).expect("parses");
|
||||||
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||||
|
|
||||||
// Without a base EUC there is nothing to compare against.
|
|
||||||
assert!(!fork.is_fork_of(&base_addr, None));
|
assert!(!fork.is_fork_of(&base_addr, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||||
// A permanent fork re-announces its EUC, the first commit after the fork.
|
let base = RepoAddr::new(
|
||||||
// Only the `u` tag still relates it to the base.
|
|
||||||
let base = crate::repo_addr(
|
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
"upstream",
|
"upstream",
|
||||||
);
|
);
|
||||||
@@ -581,8 +597,6 @@ mod tests {
|
|||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
let maintainers = announcement.effective_maintainers();
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
// The owner asserts themselves as a maintainer of the primary project, per NIP-34.
|
|
||||||
// Announced co-maintainers are included too.
|
|
||||||
assert_eq!(maintainers.len(), 2);
|
assert_eq!(maintainers.len(), 2);
|
||||||
assert!(maintainers.contains(&announcement.owner));
|
assert!(maintainers.contains(&announcement.owner));
|
||||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||||
@@ -599,8 +613,6 @@ mod tests {
|
|||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
let maintainers = announcement.effective_maintainers();
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
// A `u` tag marks the repository as a subordinate fork.
|
|
||||||
// The author is then not a maintainer of the primary project, per NIP-34.
|
|
||||||
assert!(!maintainers.contains(&announcement.owner));
|
assert!(!maintainers.contains(&announcement.owner));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
maintainers,
|
maintainers,
|
||||||
@@ -625,18 +637,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||||
// A PR references the root patch, per NIP-34.
|
|
||||||
// Later patches of the set reply to the previous one via NIP-10 `e` tags.
|
|
||||||
let root = patch_event("patch-one", vec![], 100);
|
let root = patch_event("patch-one", vec![], 100);
|
||||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pull_request_patch(&pr, [&root, &second]),
|
PullRequest::new(&pr).patch([&root, &second]),
|
||||||
"patch-one\npatch-two"
|
"patch-one\npatch-two"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pull_request_patches(&pr, [&root, &second]),
|
PullRequest::new(&pr).patches([&root, &second]),
|
||||||
vec![&root, &second]
|
vec![&root, &second]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -648,7 +658,7 @@ mod tests {
|
|||||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||||
|
|
||||||
let series = pull_request_patches(&pr, [&third, &root, &second]);
|
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
series
|
series
|
||||||
.iter()
|
.iter()
|
||||||
@@ -660,8 +670,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||||
// PRs without an `e` tag fall back to the patch producing the tip commit.
|
|
||||||
// Walk the reply chain backward to the root.
|
|
||||||
let root = patch_event("patch-one", vec![], 100);
|
let root = patch_event("patch-one", vec![], 100);
|
||||||
let tip = "1111111111111111111111111111111111111111";
|
let tip = "1111111111111111111111111111111111111111";
|
||||||
let last = patch_event(
|
let last = patch_event(
|
||||||
@@ -677,7 +685,7 @@ mod tests {
|
|||||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||||
);
|
);
|
||||||
|
|
||||||
let series = pull_request_patches(&pr, [&root, &last]);
|
let series = PullRequest::new(&pr).patches([&root, &last]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
series
|
series
|
||||||
.iter()
|
.iter()
|
||||||
@@ -721,7 +729,6 @@ mod tests {
|
|||||||
created_at,
|
created_at,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
// An update revising a different PR must be ignored even though it is newer.
|
|
||||||
let unrelated = signed_at(
|
let unrelated = signed_at(
|
||||||
Kind::GitPullRequestUpdate,
|
Kind::GitPullRequestUpdate,
|
||||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||||
@@ -729,7 +736,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
||||||
let latest = latest_update(events.iter(), &root).expect("an update");
|
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
||||||
|
|
||||||
assert_eq!(latest.created_at.as_secs(), 300);
|
assert_eq!(latest.created_at.as_secs(), 300);
|
||||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||||
@@ -749,9 +756,7 @@ mod tests {
|
|||||||
.finalize(&other)
|
.finalize(&other)
|
||||||
.expect("signed event");
|
.expect("signed event");
|
||||||
|
|
||||||
// The tip of a PR is only mutable by its author.
|
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||||
// A newer update from anyone else must not win.
|
|
||||||
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const OWNER_KEYS: [&str; 3] = [
|
const OWNER_KEYS: [&str; 3] = [
|
||||||
@@ -784,12 +789,10 @@ mod tests {
|
|||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
let clone = "https://grasp.example/npub1x/my-fork.git";
|
let clone = "https://grasp.example/npub1x/my-fork.git";
|
||||||
|
|
||||||
let base_addr = crate::repo_addr(
|
let base_addr = RepoAddr::new(
|
||||||
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
|
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
|
||||||
"upstream",
|
"upstream",
|
||||||
);
|
);
|
||||||
// Newest first, as RepoListStore keeps them.
|
|
||||||
// Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag.
|
|
||||||
let all = vec![
|
let all = vec![
|
||||||
owned_announcements(
|
owned_announcements(
|
||||||
2,
|
2,
|
||||||
@@ -819,7 +822,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
|
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
|
||||||
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
|
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user));
|
||||||
|
|
||||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
||||||
@@ -829,7 +832,7 @@ mod tests {
|
|||||||
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
|
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
|
||||||
let base_addr = crate::repo_addr(base_owner, "upstream");
|
let base_addr = RepoAddr::new(base_owner, "upstream");
|
||||||
|
|
||||||
let mut all = vec![
|
let mut all = vec![
|
||||||
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
|
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
|
||||||
@@ -859,11 +862,10 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
];
|
];
|
||||||
|
|
||||||
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner));
|
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner));
|
||||||
assert_eq!(forks.len(), 1);
|
assert_eq!(forks.len(), 1);
|
||||||
assert_eq!(forks[0].id, "mirror");
|
assert_eq!(forks[0].id, "mirror");
|
||||||
|
|
||||||
// Without a base EUC only `u`-tag forks match.
|
|
||||||
all.push(
|
all.push(
|
||||||
owned_announcements(
|
owned_announcements(
|
||||||
2,
|
2,
|
||||||
@@ -876,7 +878,7 @@ mod tests {
|
|||||||
.pop()
|
.pop()
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let forks = fork_candidates(&all, &base_addr, None, Some(base_owner));
|
let forks = Announcement::forks_in(&all, &base_addr, None, Some(base_owner));
|
||||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||||
assert_eq!(ids, vec!["u-fork"]);
|
assert_eq!(ids, vec!["u-fork"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,45 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Build a kind `30618` repository state event from refs and HEAD,
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// it is published as `ref: refs/heads/<branch>`.
|
pub struct RepoState {
|
||||||
///
|
pub refs: Vec<(String, String)>,
|
||||||
/// The `d` tag matches the repository id.
|
pub head: Option<String>,
|
||||||
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
|
||||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
|
||||||
for (name, commit) in refs {
|
|
||||||
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
|
||||||
}
|
|
||||||
if let Some(head) = head {
|
|
||||||
tags.push(
|
|
||||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a kind `30618` repository state event into refs and HEAD.
|
impl RepoState {
|
||||||
///
|
// The `d` tag matches the repository id.
|
||||||
/// `refs` are `(refname, commit-id)` pairs.
|
pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||||
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||||
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
for (name, commit) in refs {
|
||||||
let mut refs = Vec::new();
|
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||||
let mut head = None;
|
|
||||||
|
|
||||||
for tag in event.tags.iter() {
|
|
||||||
match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
|
||||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
|
||||||
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
|
||||||
}
|
|
||||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
|
||||||
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
if let Some(head) = head {
|
||||||
|
tags.push(
|
||||||
|
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
(refs, head)
|
pub fn parse(event: &Event) -> Self {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
let mut head = None;
|
||||||
|
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||||
|
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||||
|
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
||||||
|
}
|
||||||
|
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||||
|
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { refs, head }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -76,11 +77,11 @@ mod tests {
|
|||||||
&["refs/tags/v1.0", COMMIT_A],
|
&["refs/tags/v1.0", COMMIT_A],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let (refs, head) = parse_state(&event);
|
let state = RepoState::parse(&event);
|
||||||
|
|
||||||
assert_eq!(head.as_deref(), Some("main"));
|
assert_eq!(state.head.as_deref(), Some("main"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
refs,
|
state.refs,
|
||||||
vec![
|
vec![
|
||||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||||
@@ -90,22 +91,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_state_round_trips_through_parse() {
|
fn build_round_trips_through_parse() {
|
||||||
let refs = [
|
let refs = [
|
||||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||||
];
|
];
|
||||||
|
|
||||||
let event = build_state("my-repo", &refs, Some("main"))
|
let event = RepoState::build("my-repo", &refs, Some("main"))
|
||||||
.finalize(&keys())
|
.finalize(&keys())
|
||||||
.expect("signed event");
|
.expect("signed event");
|
||||||
|
|
||||||
assert_eq!(event.kind, Kind::RepoState);
|
assert_eq!(event.kind, Kind::RepoState);
|
||||||
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
||||||
|
|
||||||
let (parsed_refs, head) = parse_state(&event);
|
let state = RepoState::parse(&event);
|
||||||
assert_eq!(parsed_refs, refs);
|
assert_eq!(state.refs, refs);
|
||||||
assert_eq!(head.as_deref(), Some("main"));
|
assert_eq!(state.head.as_deref(), Some("main"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum RepoStatus {
|
pub enum RepoStatus {
|
||||||
Open,
|
Open,
|
||||||
@@ -28,42 +27,30 @@ impl RepoStatus {
|
|||||||
Self::Draft => Kind::GitStatusDraft,
|
Self::Draft => Kind::GitStatusDraft,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
// Defaults to Open when no authorized status event exists.
|
||||||
///
|
pub fn resolve<'a, I>(
|
||||||
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
status_events: I,
|
||||||
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
root_author: &PublicKey,
|
||||||
let root = root.to_hex();
|
maintainers: &[PublicKey],
|
||||||
event
|
) -> Self
|
||||||
.tags
|
where
|
||||||
.iter()
|
I: IntoIterator<Item = &'a Event>,
|
||||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
{
|
||||||
}
|
status_events
|
||||||
|
.into_iter()
|
||||||
/// Resolve the status of a root event per NIP-34.
|
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
||||||
///
|
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
||||||
/// Defaults to [`RepoStatus::Open`].
|
.max_by_key(|e| e.created_at)
|
||||||
pub fn resolve_status<'a, I>(
|
.and_then(|e| RepoStatus::from_kind(e.kind))
|
||||||
status_events: I,
|
.unwrap_or(RepoStatus::Open)
|
||||||
root_author: &PublicKey,
|
}
|
||||||
maintainers: &[PublicKey],
|
|
||||||
) -> RepoStatus
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = &'a Event>,
|
|
||||||
{
|
|
||||||
status_events
|
|
||||||
.into_iter()
|
|
||||||
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
|
||||||
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
|
||||||
.max_by_key(|e| e.created_at)
|
|
||||||
.and_then(|e| RepoStatus::from_kind(e.kind))
|
|
||||||
.unwrap_or(RepoStatus::Open)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::GitEvent;
|
||||||
|
|
||||||
const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||||
const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
|
const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
|
||||||
@@ -94,11 +81,8 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.expect("signed event");
|
.expect("signed event");
|
||||||
|
|
||||||
assert!(references_root(&event, &root));
|
assert!(event.references_root(&root));
|
||||||
assert!(!references_root(
|
assert!(!event.references_root(&EventId::from_hex(OTHER_ID_HEX).expect("valid id")));
|
||||||
&event,
|
|
||||||
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -115,7 +99,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_status(
|
RepoStatus::resolve(
|
||||||
statuses.iter(),
|
statuses.iter(),
|
||||||
&owner.public_key(),
|
&owner.public_key(),
|
||||||
&[maintainer.public_key()]
|
&[maintainer.public_key()]
|
||||||
@@ -140,7 +124,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_status(
|
RepoStatus::resolve(
|
||||||
statuses.iter(),
|
statuses.iter(),
|
||||||
&owner.public_key(),
|
&owner.public_key(),
|
||||||
&[maintainer.public_key()]
|
&[maintainer.public_key()]
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
|
||||||
use crate::remote::{clone_repo, fetch_all};
|
use crate::repo::Repo;
|
||||||
|
|
||||||
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GitCache {
|
pub struct GitCache {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
@@ -22,30 +21,25 @@ impl GitCache {
|
|||||||
|
|
||||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||||
self.root
|
self.root
|
||||||
.join(addr.public_key.to_hex())
|
.join(addr.public_key().to_hex())
|
||||||
.join(sanitize_path_component(&addr.identifier))
|
.join(Self::sanitize_path_component(addr.identifier()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
|
||||||
let path = self.repo_path(addr);
|
let path = self.repo_path(addr);
|
||||||
match gix::open(&path) {
|
match gix::open(&path) {
|
||||||
Ok(repo) => Ok(Some(repo)),
|
Ok(repo) => Ok(Some(Repo { inner: repo })),
|
||||||
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
|
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
|
||||||
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the existing clone, fetching it first.
|
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||||
pub fn ensure_clone<U: AsRef<str>>(
|
|
||||||
&self,
|
|
||||||
addr: &RepoAddr,
|
|
||||||
clone_urls: &[U],
|
|
||||||
) -> Result<gix::Repository> {
|
|
||||||
let path = self.repo_path(addr);
|
let path = self.repo_path(addr);
|
||||||
|
|
||||||
if let Some(repo) = self.open(addr)? {
|
if let Some(repo) = self.open(addr)? {
|
||||||
fetch_all(&repo).ok();
|
repo.fetch().ok();
|
||||||
return Ok(repo);
|
return Ok(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,37 +48,35 @@ impl GitCache {
|
|||||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
clone_repo(clone_urls, &path)?;
|
Repo::clone(clone_urls, &path)
|
||||||
self.open(addr)?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map an untrusted repository id or display name to a safe single path component.
|
|
||||||
pub fn sanitize_path_component(id: &str) -> String {
|
|
||||||
let sanitized: String = id
|
|
||||||
.chars()
|
|
||||||
.map(|c| {
|
|
||||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
|
||||||
c
|
|
||||||
} else {
|
|
||||||
'_'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if sanitized == "." || sanitized == ".." {
|
|
||||||
return "_".to_owned();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitized
|
// `id` is untrusted relay content: it must never escape the cache root as
|
||||||
}
|
// a single path component.
|
||||||
|
pub fn sanitize_path_component(id: &str) -> String {
|
||||||
|
let sanitized: String = id
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
/// The refs namespace of a fork's import in the target mirror.
|
if sanitized == "." || sanitized == ".." {
|
||||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
return "_".to_owned();
|
||||||
format!(
|
}
|
||||||
"{}/{}",
|
|
||||||
announcement.owner.to_hex(),
|
sanitized
|
||||||
sanitize_path_component(&announcement.id)
|
}
|
||||||
)
|
|
||||||
|
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
announcement.owner.to_hex(),
|
||||||
|
Self::sanitize_path_component(&announcement.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-166
@@ -1,11 +1,10 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||||
|
|
||||||
|
use crate::repo::Repo;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum DiffLineKind {
|
pub enum DiffLineKind {
|
||||||
/// An unchanged context line, present on both sides.
|
|
||||||
Context,
|
Context,
|
||||||
Addition,
|
Addition,
|
||||||
Deletion,
|
Deletion,
|
||||||
@@ -14,21 +13,15 @@ pub enum DiffLineKind {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DiffLine {
|
pub struct DiffLine {
|
||||||
pub kind: DiffLineKind,
|
pub kind: DiffLineKind,
|
||||||
/// 1-based line number in the old version, if the line exists there.
|
|
||||||
pub old: Option<u32>,
|
pub old: Option<u32>,
|
||||||
/// 1-based line number in the new version, if the line exists there.
|
|
||||||
pub new: Option<u32>,
|
pub new: Option<u32>,
|
||||||
/// Line content without the trailing newline.
|
|
||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DiffHunk {
|
pub struct DiffHunk {
|
||||||
/// 1-based start line in the old version.
|
|
||||||
pub old_start: u32,
|
pub old_start: u32,
|
||||||
pub old_lines: u32,
|
pub old_lines: u32,
|
||||||
/// 1-based start line in the new version.
|
|
||||||
pub new_start: u32,
|
pub new_start: u32,
|
||||||
pub new_lines: u32,
|
pub new_lines: u32,
|
||||||
pub lines: Vec<DiffLine>,
|
pub lines: Vec<DiffLine>,
|
||||||
@@ -45,18 +38,13 @@ pub enum DiffStatus {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileDiff {
|
pub struct FileDiff {
|
||||||
/// Path of the file relative to the repo root.
|
// For renames and copies, the destination path.
|
||||||
///
|
|
||||||
/// For renames and copies, this is the destination path.
|
|
||||||
pub path: String,
|
pub path: String,
|
||||||
/// Previous path, for renames and copies.
|
|
||||||
pub old_path: Option<String>,
|
pub old_path: Option<String>,
|
||||||
pub status: DiffStatus,
|
pub status: DiffStatus,
|
||||||
/// Number of added lines, 0 for binary files.
|
|
||||||
pub insertions: usize,
|
pub insertions: usize,
|
||||||
/// Number of removed lines, 0 for binary files.
|
|
||||||
pub deletions: usize,
|
pub deletions: usize,
|
||||||
/// True if either version is binary, then `hunks` is empty.
|
// Binary files have empty `hunks`.
|
||||||
pub binary: bool,
|
pub binary: bool,
|
||||||
pub hunks: Vec<DiffHunk>,
|
pub hunks: Vec<DiffHunk>,
|
||||||
}
|
}
|
||||||
@@ -66,162 +54,158 @@ pub struct CommitDiff {
|
|||||||
pub files: Vec<FileDiff>,
|
pub files: Vec<FileDiff>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
|
impl Repo {
|
||||||
///
|
// Compared against the first parent; the empty tree for the root commit.
|
||||||
/// Compared against its first parent, the empty tree for the root commit.
|
pub fn commit_diff(&self, id: &str) -> Result<CommitDiff> {
|
||||||
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
let commit_id = self.inner.rev_parse_single(id.as_bytes())?;
|
||||||
commit_diff(&gix::open(workdir)?, id)
|
let commit = commit_id.object()?.into_commit();
|
||||||
}
|
let new_tree = commit.tree()?;
|
||||||
|
let old_tree = match commit.parent_ids().next() {
|
||||||
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||||
let commit_id = repo.rev_parse_single(id.as_bytes())?;
|
None => None,
|
||||||
let commit = commit_id.object()?.into_commit();
|
|
||||||
let new_tree = commit.tree()?;
|
|
||||||
let old_tree = match commit.parent_ids().next() {
|
|
||||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
tree_diff(repo, old_tree.as_ref(), &new_tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The changes between two commits, `base`..`tip`, like `git diff base tip`.
|
|
||||||
///
|
|
||||||
/// Directories and submodules are skipped, files are sorted by path.
|
|
||||||
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
|
|
||||||
let repo = gix::open(workdir)?;
|
|
||||||
let base_tree = repo
|
|
||||||
.rev_parse_single(base.as_bytes())?
|
|
||||||
.object()?
|
|
||||||
.into_commit()
|
|
||||||
.tree()?;
|
|
||||||
let tip_tree = repo
|
|
||||||
.rev_parse_single(tip.as_bytes())?
|
|
||||||
.object()?
|
|
||||||
.into_commit()
|
|
||||||
.tree()?;
|
|
||||||
tree_diff(&repo, Some(&base_tree), &tip_tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tree_diff(
|
|
||||||
repo: &gix::Repository,
|
|
||||||
old_tree: Option<&gix::Tree<'_>>,
|
|
||||||
new_tree: &gix::Tree<'_>,
|
|
||||||
) -> Result<CommitDiff> {
|
|
||||||
use gix::diff::blob::platform::prepare_diff::Operation;
|
|
||||||
use gix::object::tree::diff::Change;
|
|
||||||
use gix::objs::tree::EntryKind;
|
|
||||||
|
|
||||||
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
|
||||||
|
|
||||||
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
|
|
||||||
let mut files = Vec::new();
|
|
||||||
|
|
||||||
for change in changes {
|
|
||||||
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
|
|
||||||
|
|
||||||
// Skip directory trees and submodule gitlinks, only files are listed.
|
|
||||||
let (path, old_path, status) = match attached {
|
|
||||||
Change::Addition {
|
|
||||||
location,
|
|
||||||
entry_mode,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
|
||||||
(location.to_owned(), None, DiffStatus::Added)
|
|
||||||
}
|
|
||||||
Change::Deletion {
|
|
||||||
location,
|
|
||||||
entry_mode,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
|
||||||
(location.to_owned(), None, DiffStatus::Deleted)
|
|
||||||
}
|
|
||||||
Change::Modification {
|
|
||||||
location,
|
|
||||||
previous_entry_mode,
|
|
||||||
entry_mode,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
|
||||||
&& !matches!(
|
|
||||||
previous_entry_mode.kind(),
|
|
||||||
EntryKind::Tree | EntryKind::Commit
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
(location.to_owned(), None, DiffStatus::Modified)
|
|
||||||
}
|
|
||||||
Change::Rewrite {
|
|
||||||
location,
|
|
||||||
source_location,
|
|
||||||
source_entry_mode,
|
|
||||||
entry_mode,
|
|
||||||
copy,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
|
||||||
&& !matches!(
|
|
||||||
source_entry_mode.kind(),
|
|
||||||
EntryKind::Tree | EntryKind::Commit
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
let status = if copy {
|
|
||||||
DiffStatus::Copied
|
|
||||||
} else {
|
|
||||||
DiffStatus::Renamed
|
|
||||||
};
|
|
||||||
(
|
|
||||||
location.to_owned(),
|
|
||||||
Some(source_location.to_owned()),
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
};
|
};
|
||||||
|
Self::tree_diff(self, old_tree.as_ref(), &new_tree)
|
||||||
// Always diff with the built-in algorithm.
|
|
||||||
// External diff drivers would shell out, out of scope for a read-only viewer.
|
|
||||||
let platform = attached.diff(&mut cache)?;
|
|
||||||
platform
|
|
||||||
.resource_cache
|
|
||||||
.options
|
|
||||||
.skip_internal_diff_if_external_is_configured = true;
|
|
||||||
let outcome = platform.resource_cache.prepare_diff()?;
|
|
||||||
|
|
||||||
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
|
||||||
Operation::InternalDiff { algorithm } => {
|
|
||||||
let input = outcome.interned_input();
|
|
||||||
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
|
||||||
|
|
||||||
let mut hunks = Vec::new();
|
|
||||||
let mut insertions = 0usize;
|
|
||||||
let mut deletions = 0usize;
|
|
||||||
let collector = HunkCollector {
|
|
||||||
hunks: &mut hunks,
|
|
||||||
insertions: &mut insertions,
|
|
||||||
deletions: &mut deletions,
|
|
||||||
};
|
|
||||||
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
|
||||||
.consume()?;
|
|
||||||
(false, hunks, insertions, deletions)
|
|
||||||
}
|
|
||||||
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
|
||||||
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
|
|
||||||
};
|
|
||||||
|
|
||||||
files.push(FileDiff {
|
|
||||||
path: String::from_utf8_lossy(&path).into_owned(),
|
|
||||||
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
|
||||||
status,
|
|
||||||
insertions,
|
|
||||||
deletions,
|
|
||||||
binary,
|
|
||||||
hunks,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
// Directories and submodules are skipped, files are sorted by path.
|
||||||
|
pub fn range_diff(&self, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||||
|
let base_tree = self
|
||||||
|
.inner
|
||||||
|
.rev_parse_single(base.as_bytes())?
|
||||||
|
.object()?
|
||||||
|
.into_commit()
|
||||||
|
.tree()?;
|
||||||
|
let tip_tree = self
|
||||||
|
.inner
|
||||||
|
.rev_parse_single(tip.as_bytes())?
|
||||||
|
.object()?
|
||||||
|
.into_commit()
|
||||||
|
.tree()?;
|
||||||
|
Self::tree_diff(self, Some(&base_tree), &tip_tree)
|
||||||
|
}
|
||||||
|
|
||||||
Ok(CommitDiff { files })
|
fn tree_diff(
|
||||||
|
repo: &Repo,
|
||||||
|
old_tree: Option<&gix::Tree<'_>>,
|
||||||
|
new_tree: &gix::Tree<'_>,
|
||||||
|
) -> Result<CommitDiff> {
|
||||||
|
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||||
|
use gix::object::tree::diff::Change;
|
||||||
|
use gix::objs::tree::EntryKind;
|
||||||
|
|
||||||
|
let changes = repo
|
||||||
|
.inner
|
||||||
|
.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||||
|
|
||||||
|
let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?;
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
for change in changes {
|
||||||
|
let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner);
|
||||||
|
|
||||||
|
let (path, old_path, status) = match attached {
|
||||||
|
Change::Addition {
|
||||||
|
location,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||||
|
(location.to_owned(), None, DiffStatus::Added)
|
||||||
|
}
|
||||||
|
Change::Deletion {
|
||||||
|
location,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||||
|
(location.to_owned(), None, DiffStatus::Deleted)
|
||||||
|
}
|
||||||
|
Change::Modification {
|
||||||
|
location,
|
||||||
|
previous_entry_mode,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||||
|
&& !matches!(
|
||||||
|
previous_entry_mode.kind(),
|
||||||
|
EntryKind::Tree | EntryKind::Commit
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
(location.to_owned(), None, DiffStatus::Modified)
|
||||||
|
}
|
||||||
|
Change::Rewrite {
|
||||||
|
location,
|
||||||
|
source_location,
|
||||||
|
source_entry_mode,
|
||||||
|
entry_mode,
|
||||||
|
copy,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||||
|
&& !matches!(
|
||||||
|
source_entry_mode.kind(),
|
||||||
|
EntryKind::Tree | EntryKind::Commit
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
let status = if copy {
|
||||||
|
DiffStatus::Copied
|
||||||
|
} else {
|
||||||
|
DiffStatus::Renamed
|
||||||
|
};
|
||||||
|
(
|
||||||
|
location.to_owned(),
|
||||||
|
Some(source_location.to_owned()),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// External diff drivers would shell out, out of scope for a read-only viewer.
|
||||||
|
let platform = attached.diff(&mut cache)?;
|
||||||
|
platform
|
||||||
|
.resource_cache
|
||||||
|
.options
|
||||||
|
.skip_internal_diff_if_external_is_configured = true;
|
||||||
|
let outcome = platform.resource_cache.prepare_diff()?;
|
||||||
|
|
||||||
|
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
||||||
|
Operation::InternalDiff { algorithm } => {
|
||||||
|
let input = outcome.interned_input();
|
||||||
|
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
||||||
|
|
||||||
|
let mut hunks = Vec::new();
|
||||||
|
let mut insertions = 0usize;
|
||||||
|
let mut deletions = 0usize;
|
||||||
|
let collector = HunkCollector {
|
||||||
|
hunks: &mut hunks,
|
||||||
|
insertions: &mut insertions,
|
||||||
|
deletions: &mut deletions,
|
||||||
|
};
|
||||||
|
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
||||||
|
.consume()?;
|
||||||
|
(false, hunks, insertions, deletions)
|
||||||
|
}
|
||||||
|
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
||||||
|
Operation::ExternalCommand { .. } => {
|
||||||
|
unreachable!("external diff drivers are disabled")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
files.push(FileDiff {
|
||||||
|
path: String::from_utf8_lossy(&path).into_owned(),
|
||||||
|
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
||||||
|
status,
|
||||||
|
insertions,
|
||||||
|
deletions,
|
||||||
|
binary,
|
||||||
|
hunks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
|
||||||
|
Ok(CommitDiff { files })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
|
||||||
struct HunkCollector<'a> {
|
struct HunkCollector<'a> {
|
||||||
hunks: &'a mut Vec<DiffHunk>,
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
insertions: &'a mut usize,
|
insertions: &'a mut usize,
|
||||||
|
|||||||
+161
-213
@@ -3,246 +3,194 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// In-memory object cache for history walks, see [`open_with_cache`].
|
use crate::repo::Repo;
|
||||||
///
|
|
||||||
/// Without one, a walk re-decodes the same commit objects from the object database.
|
|
||||||
/// Sized generously: a walk can cover a large portion of the repository's history.
|
|
||||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Metadata of a commit, as shown in the repository browser's file header.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileCommit {
|
pub struct FileCommit {
|
||||||
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
|
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// First line of the commit message.
|
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
/// Rest of the commit message after the title.
|
|
||||||
///
|
|
||||||
/// `None` for single-line commit messages.
|
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
/// Author time, seconds since the Unix epoch.
|
|
||||||
pub time: i64,
|
pub time: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the repository at `workdir` with an in-memory object cache.
|
impl FileCommit {
|
||||||
///
|
fn from_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||||
/// Only history walks use it, they re-decode the same commit objects repeatedly.
|
Self::from_commit_with_description(commit, true)
|
||||||
/// Single-object reads open the repository plain.
|
|
||||||
pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
|
||||||
let mut repo = gix::open(workdir)?;
|
|
||||||
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
|
||||||
Ok(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A [`FileCommit`] with author, message title, body and shortened id.
|
|
||||||
///
|
|
||||||
/// The diff panel fetches the full commit on demand.
|
|
||||||
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
|
||||||
file_commit_with_description(commit, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A [`FileCommit`] without the message body, for history lists that never display it.
|
|
||||||
///
|
|
||||||
/// Skipping the body saves an allocation per listed commit.
|
|
||||||
fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
|
||||||
file_commit_with_description(commit, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file_commit_with_description(
|
|
||||||
commit: &gix::Commit<'_>,
|
|
||||||
include_description: bool,
|
|
||||||
) -> Result<FileCommit> {
|
|
||||||
let author = commit.author()?;
|
|
||||||
let message = commit.message()?;
|
|
||||||
|
|
||||||
Ok(FileCommit {
|
|
||||||
id: commit.id().shorten_or_id().to_string(),
|
|
||||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
|
||||||
description: if include_description {
|
|
||||||
message
|
|
||||||
.body
|
|
||||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
|
||||||
.filter(|body| !body.is_empty())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
|
||||||
time: author.time()?.seconds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
|
||||||
/// `rels` are paths relative to the worktree.
|
|
||||||
///
|
|
||||||
/// Paths without any commit, like untracked files, are absent from the result.
|
|
||||||
pub fn worktree_last_commits(
|
|
||||||
workdir: &Path,
|
|
||||||
rels: &[PathBuf],
|
|
||||||
) -> Result<Vec<(PathBuf, FileCommit)>> {
|
|
||||||
last_commits(&open_with_cache(workdir)?, rels)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stops as soon as every pending path has its commit.
|
|
||||||
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
|
||||||
|
|
||||||
let Some(head) = repo.head_id().ok() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
|
||||||
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
|
||||||
|
|
||||||
for rel in rels {
|
|
||||||
if seen.insert(rel.as_path()) {
|
|
||||||
pending.push(rel.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let walk = repo
|
// History lists never display the body,
|
||||||
.rev_walk([head])
|
// skipping it saves an allocation per listed commit.
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
fn from_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||||
CommitTimeOrder::NewestFirst,
|
Self::from_commit_with_description(commit, false)
|
||||||
));
|
}
|
||||||
|
|
||||||
let mut found = Vec::new();
|
fn from_commit_with_description(
|
||||||
for info in walk.all()? {
|
commit: &gix::Commit<'_>,
|
||||||
if pending.is_empty() {
|
include_description: bool,
|
||||||
break;
|
) -> Result<FileCommit> {
|
||||||
}
|
let author = commit.author()?;
|
||||||
let info = info?;
|
let message = commit.message()?;
|
||||||
let commit = info.object()?;
|
|
||||||
let tree = commit.tree()?;
|
Ok(FileCommit {
|
||||||
let parent_tree = match info.parent_ids().next() {
|
id: commit.id().shorten_or_id().to_string(),
|
||||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||||
None => None,
|
description: if include_description {
|
||||||
|
message
|
||||||
|
.body
|
||||||
|
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||||
|
time: author.time()?.seconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Repo {
|
||||||
|
// Paths without any commit, like untracked files, are absent from the result.
|
||||||
|
pub fn last_commits(&self, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||||
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
|
let Some(head) = self.inner.head_id().ok() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compare each unresolved path against this commit and its first parent.
|
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||||
let mut ix = 0;
|
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||||
while ix < pending.len() {
|
|
||||||
let rel = &pending[ix];
|
for rel in rels {
|
||||||
let blob = tree.lookup_entry_by_path(rel)?;
|
if seen.insert(rel.as_path()) {
|
||||||
let parent_blob = match &parent_tree {
|
pending.push(rel.clone());
|
||||||
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let walk = self
|
||||||
|
.inner
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
CommitTimeOrder::NewestFirst,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for info in walk.all()? {
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let info = info?;
|
||||||
|
let commit = info.object()?;
|
||||||
|
let tree = commit.tree()?;
|
||||||
|
let parent_tree = match info.parent_ids().next() {
|
||||||
|
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
// Compare each unresolved path against this commit and its first parent.
|
||||||
{
|
let mut ix = 0;
|
||||||
found.push((rel.clone(), file_commit(&commit)?));
|
while ix < pending.len() {
|
||||||
pending.swap_remove(ix);
|
let rel = &pending[ix];
|
||||||
} else {
|
let blob = tree.lookup_entry_by_path(rel)?;
|
||||||
ix += 1;
|
let parent_blob = match &parent_tree {
|
||||||
|
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if blob.map(|entry| entry.id().detach())
|
||||||
|
!= parent_blob.map(|entry| entry.id().detach())
|
||||||
|
{
|
||||||
|
found.push((rel.clone(), FileCommit::from_commit(&commit)?));
|
||||||
|
pending.swap_remove(ix);
|
||||||
|
} else {
|
||||||
|
ix += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(found)
|
pub fn all_commits(&self) -> Result<CommitList> {
|
||||||
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
|
let Some(head) = self.inner.head_id().ok() else {
|
||||||
|
return Ok(CommitList {
|
||||||
|
total: 0,
|
||||||
|
commits: Vec::new(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let walk = self
|
||||||
|
.inner
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
CommitTimeOrder::NewestFirst,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
let mut total = 0;
|
||||||
|
|
||||||
|
for info in walk.all()? {
|
||||||
|
let info = info?;
|
||||||
|
total += 1;
|
||||||
|
if commits.len() < MAX_LISTED_COMMITS {
|
||||||
|
commits.push(FileCommit::from_commit_summary(&info.object()?)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CommitList { total, commits })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_range(&self, base: &str, tip: &str) -> Result<Vec<FileCommit>> {
|
||||||
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
|
let base_id = self.inner.rev_parse_single(base.as_bytes())?;
|
||||||
|
let tip_id = self.inner.rev_parse_single(tip.as_bytes())?;
|
||||||
|
let walk = self
|
||||||
|
.inner
|
||||||
|
.rev_walk([tip_id])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
CommitTimeOrder::NewestFirst,
|
||||||
|
))
|
||||||
|
.with_hidden([base_id]);
|
||||||
|
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
|
||||||
|
for info in walk.all()? {
|
||||||
|
let info = info?;
|
||||||
|
commits.push(FileCommit::from_commit_summary(&info.object()?)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Ok(None)` for an unborn HEAD.
|
||||||
|
pub fn head_commit(&self) -> Result<Option<FileCommit>> {
|
||||||
|
let Some(head) = self.inner.head_id().ok() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let commit = head.object()?.into_commit();
|
||||||
|
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Ok(None)` when the id cannot be resolved.
|
||||||
|
pub fn commit(&self, id: &str) -> Result<Option<FileCommit>> {
|
||||||
|
match self.inner.rev_parse_single(id.as_bytes()) {
|
||||||
|
Ok(commit_id) => {
|
||||||
|
let commit = commit_id.object()?.into_commit();
|
||||||
|
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||||
|
}
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
|
// The virtual list renders a window at a time, the tab badge shows the real
|
||||||
/// the tab badge shows the real count.
|
// count: a huge history is never fully materialized in memory.
|
||||||
///
|
|
||||||
/// A huge history is never fully materialized in memory.
|
|
||||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||||
|
|
||||||
/// Commits reachable from `HEAD`, newest first, possibly capped.
|
|
||||||
pub struct CommitList {
|
pub struct CommitList {
|
||||||
/// Number of commits reachable from HEAD.
|
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
|
||||||
pub commits: Vec<FileCommit>,
|
pub commits: Vec<FileCommit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
|
||||||
///
|
|
||||||
/// Returns an empty list for a repository without any commits yet.
|
|
||||||
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
|
||||||
|
|
||||||
let Some(head) = repo.head_id().ok() else {
|
|
||||||
return Ok(CommitList {
|
|
||||||
total: 0,
|
|
||||||
commits: Vec::new(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let walk = repo
|
|
||||||
.rev_walk([head])
|
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
|
||||||
CommitTimeOrder::NewestFirst,
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut commits = Vec::new();
|
|
||||||
let mut total = 0;
|
|
||||||
|
|
||||||
for info in walk.all()? {
|
|
||||||
let info = info?;
|
|
||||||
total += 1;
|
|
||||||
if commits.len() < MAX_LISTED_COMMITS {
|
|
||||||
commits.push(file_commit_summary(&info.object()?)?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CommitList { total, commits })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// For non-bare clones the clone root is the worktree.
|
|
||||||
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
|
||||||
all_commits(&open_with_cache(workdir)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
|
|
||||||
pub fn worktree_commit_range_commits(
|
|
||||||
workdir: &Path,
|
|
||||||
base: &str,
|
|
||||||
tip: &str,
|
|
||||||
) -> Result<Vec<FileCommit>> {
|
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
|
||||||
|
|
||||||
let repo = open_with_cache(workdir)?;
|
|
||||||
let base_id = repo.rev_parse_single(base.as_bytes())?;
|
|
||||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
|
||||||
let walk = repo
|
|
||||||
.rev_walk([tip_id])
|
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
|
||||||
CommitTimeOrder::NewestFirst,
|
|
||||||
))
|
|
||||||
.with_hidden([base_id]);
|
|
||||||
|
|
||||||
let mut commits = Vec::new();
|
|
||||||
|
|
||||||
for info in walk.all()? {
|
|
||||||
let info = info?;
|
|
||||||
commits.push(file_commit_summary(&info.object()?)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(commits)
|
|
||||||
}
|
|
||||||
/// The commit HEAD points to, like `git log -1`.
|
|
||||||
///
|
|
||||||
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
|
||||||
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
|
||||||
let Some(head) = repo.head_id().ok() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let commit = head.object()?.into_commit();
|
|
||||||
Ok(Some(file_commit(&commit)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
|
|
||||||
/// Like [`head_commit`] for an arbitrary commit.
|
|
||||||
///
|
|
||||||
/// `Ok(None)` when the id cannot be resolved.
|
|
||||||
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
|
|
||||||
let repo = gix::open(workdir)?;
|
|
||||||
match repo.rev_parse_single(id.as_bytes()) {
|
|
||||||
Ok(commit_id) => {
|
|
||||||
let commit = commit_id.object()?.into_commit();
|
|
||||||
Ok(Some(file_commit(&commit)?))
|
|
||||||
}
|
|
||||||
Err(_) => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,41 +11,18 @@ mod worktree;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
pub use cache::{GitCache, fork_namespace, sanitize_path_component};
|
pub use cache::GitCache;
|
||||||
pub use diff::{
|
pub use diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff,
|
pub use history::{CommitList, FileCommit, MAX_LISTED_COMMITS};
|
||||||
worktree_commit_range_diff,
|
pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
|
||||||
};
|
pub use patch::PatchParser;
|
||||||
pub use history::{
|
pub use repo::{Repo, RepoRefState};
|
||||||
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits,
|
|
||||||
worktree_commit, worktree_commit_range_commits, worktree_last_commits,
|
|
||||||
};
|
|
||||||
pub use nip34::{
|
|
||||||
GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url, set_nostr_repo,
|
|
||||||
};
|
|
||||||
pub use patch::{
|
|
||||||
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
|
|
||||||
};
|
|
||||||
pub use remote::{
|
|
||||||
clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref,
|
|
||||||
push_main, remote_has_refs, set_origin,
|
|
||||||
};
|
|
||||||
pub use repo::{
|
|
||||||
RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches,
|
|
||||||
head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state,
|
|
||||||
repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists,
|
|
||||||
worktree_ref_state,
|
|
||||||
};
|
|
||||||
pub use scan::{LocalRepo, find_git_repos};
|
pub use scan::{LocalRepo, find_git_repos};
|
||||||
pub use worktree::{
|
pub use worktree::WorktreeSnapshot;
|
||||||
WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag,
|
|
||||||
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
|
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
|
||||||
let output = remote::git_output(dir, args, "git")?;
|
let output = Repo::run_git(dir, args, "git")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
+205
-199
@@ -1,17 +1,17 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use gix::bstr::ByteSlice;
|
use gix::bstr::ByteSlice;
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
use crate::repo::Repo;
|
||||||
|
|
||||||
/// The kind of NIP-34 relationship a local repository has on disk.
|
/// The kind of NIP-34 relationship a local repository has on disk.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Nip34Kind {
|
pub enum Nip34Kind {
|
||||||
/// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
// By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||||
Initialized,
|
Initialized,
|
||||||
/// Cloned from a `nostr://` remote but never initialized locally.
|
// Cloned from a `nostr://` remote but never initialized locally.
|
||||||
Cloned,
|
Cloned,
|
||||||
/// Nostr tooling touched the repository but no binding is recoverable.
|
// Nostr tooling touched the repository but no binding is recoverable.
|
||||||
ToolingOnly,
|
ToolingOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ pub struct GraspSignals {
|
|||||||
pub nostr_repo_config: bool,
|
pub nostr_repo_config: bool,
|
||||||
pub nostr_remote: bool,
|
pub nostr_remote: bool,
|
||||||
pub grasp_remote: bool,
|
pub grasp_remote: bool,
|
||||||
/// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/<host>` remote name.
|
// `nak`-specific `nip34/grasp/<host>` remote name.
|
||||||
pub nip34_grasp_remote: bool,
|
pub nip34_grasp_remote: bool,
|
||||||
pub nip34_state_refs: bool,
|
pub nip34_state_refs: bool,
|
||||||
pub nostr_cache: bool,
|
pub nostr_cache: bool,
|
||||||
@@ -41,7 +41,7 @@ impl GraspSignals {
|
|||||||
pub struct Nip34Binding {
|
pub struct Nip34Binding {
|
||||||
pub kind: Nip34Kind,
|
pub kind: Nip34Kind,
|
||||||
pub signals: GraspSignals,
|
pub signals: GraspSignals,
|
||||||
/// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`.
|
// From `nip34.json` or `nostr.repo`.
|
||||||
pub owner: Option<PublicKey>,
|
pub owner: Option<PublicKey>,
|
||||||
pub identifier: Option<String>,
|
pub identifier: Option<String>,
|
||||||
pub grasp_urls: Vec<String>,
|
pub grasp_urls: Vec<String>,
|
||||||
@@ -53,222 +53,221 @@ struct Nip34Json {
|
|||||||
owner: Option<String>,
|
owner: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding> {
|
impl Repo {
|
||||||
let repo = gix::open(repo_path).ok()?;
|
// `None` when no Nostr tooling left a marker.
|
||||||
let common_dir = repo.common_dir().to_path_buf();
|
pub fn nip34_binding(&self) -> Option<Nip34Binding> {
|
||||||
let workdir = repo.workdir().map(Path::to_path_buf);
|
let common_dir = self.inner.common_dir().to_path_buf();
|
||||||
|
let workdir = self.inner.workdir().map(std::path::Path::to_path_buf);
|
||||||
|
|
||||||
let mut signals = GraspSignals::default();
|
let mut signals = GraspSignals::default();
|
||||||
let mut owner: Option<PublicKey> = None;
|
let mut owner: Option<PublicKey> = None;
|
||||||
let mut identifier: Option<String> = None;
|
let mut identifier: Option<String> = None;
|
||||||
let mut grasp_urls: Vec<String> = Vec::new();
|
let mut grasp_urls: Vec<String> = Vec::new();
|
||||||
|
|
||||||
if let Some(workdir) = &workdir {
|
if let Some(workdir) = &workdir {
|
||||||
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
|
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
|
||||||
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
|
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
|
||||||
{
|
{
|
||||||
signals.nip34_json = true;
|
signals.nip34_json = true;
|
||||||
identifier = config.identifier.and_then(non_empty);
|
identifier = config.identifier.and_then(Self::non_empty);
|
||||||
owner = config
|
owner = config
|
||||||
.owner
|
.owner
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|value| PublicKey::parse(value).ok());
|
.and_then(|value| PublicKey::parse(value).ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
if workdir.join("maintainers.yaml").is_file() {
|
if workdir.join("maintainers.yaml").is_file() {
|
||||||
signals.maintainers_yaml = true;
|
signals.maintainers_yaml = true;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
|
|
||||||
&& exclude.contains("nip34.json")
|
|
||||||
{
|
|
||||||
signals.nip34_excluded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `ngit` keeps its repository event cache in the Git common directory.
|
|
||||||
if common_dir.join("nostr-cache.lmdb").is_file() {
|
|
||||||
signals.nostr_cache = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
|
|
||||||
if let Ok(config) = gix::config::File::from_path_no_includes(
|
|
||||||
common_dir.join("config"),
|
|
||||||
gix::config::Source::Local,
|
|
||||||
) {
|
|
||||||
if let Some(value) = config.string("nostr.repo")
|
|
||||||
&& let Some((key, id)) = coordinate_from_naddr(&value.to_str_lossy())
|
|
||||||
{
|
|
||||||
signals.nostr_repo_config = true;
|
|
||||||
owner = Some(key);
|
|
||||||
identifier = Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
|
|
||||||
if config.string(key).is_some() {
|
|
||||||
signals.nostr_aux_config = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sections) = config.sections_by_name("remote") {
|
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
|
||||||
for section in sections {
|
&& exclude.contains("nip34.json")
|
||||||
let Some(name) = section.header().subsection_name() else {
|
{
|
||||||
continue;
|
signals.nip34_excluded = true;
|
||||||
};
|
}
|
||||||
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
|
|
||||||
|
|
||||||
for url in section.values("url") {
|
// `ngit` keeps its repository event cache in the Git common directory.
|
||||||
let url = url.to_str_lossy();
|
if common_dir.join("nostr-cache.lmdb").is_file() {
|
||||||
|
signals.nostr_cache = true;
|
||||||
|
}
|
||||||
|
|
||||||
if url.starts_with("nostr://") {
|
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
|
||||||
signals.nostr_remote = true;
|
if let Ok(config) = gix::config::File::from_path_no_includes(
|
||||||
// Strong markers win; only fill an empty binding.
|
common_dir.join("config"),
|
||||||
if owner.is_none()
|
gix::config::Source::Local,
|
||||||
&& identifier.is_none()
|
) {
|
||||||
&& let Some((key, id)) = parse_nostr_url(&url)
|
if let Some(value) = config.string("nostr.repo")
|
||||||
{
|
&& let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy())
|
||||||
owner = Some(key);
|
{
|
||||||
identifier = Some(id);
|
signals.nostr_repo_config = true;
|
||||||
|
owner = Some(key);
|
||||||
|
identifier = Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
|
||||||
|
if config.string(key).is_some() {
|
||||||
|
signals.nostr_aux_config = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sections) = config.sections_by_name("remote") {
|
||||||
|
for section in sections {
|
||||||
|
let Some(name) = section.header().subsection_name() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
|
||||||
|
|
||||||
|
for url in section.values("url") {
|
||||||
|
let url = url.to_str_lossy();
|
||||||
|
|
||||||
|
if url.starts_with("nostr://") {
|
||||||
|
signals.nostr_remote = true;
|
||||||
|
// Strong markers win; only fill an empty binding.
|
||||||
|
if owner.is_none()
|
||||||
|
&& identifier.is_none()
|
||||||
|
&& let Some((key, id)) = Self::parse_nostr_url(&url)
|
||||||
|
{
|
||||||
|
owner = Some(key);
|
||||||
|
identifier = Some(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if is_grasp_url(&url) {
|
if Self::is_grasp_url(&url) {
|
||||||
signals.grasp_remote = true;
|
signals.grasp_remote = true;
|
||||||
signals.nip34_grasp_remote |= nak_grasp_remote;
|
signals.nip34_grasp_remote |= nak_grasp_remote;
|
||||||
grasp_urls.push(url.to_string());
|
grasp_urls.push(url.to_string());
|
||||||
|
|
||||||
if owner.is_none()
|
if owner.is_none()
|
||||||
&& identifier.is_none()
|
&& identifier.is_none()
|
||||||
&& let Some((key, id)) = grasp_parts(&url)
|
&& let Some((key, id)) = Self::grasp_parts(&url)
|
||||||
{
|
{
|
||||||
owner = Some(key);
|
owner = Some(key);
|
||||||
identifier = Some(id);
|
identifier = Some(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
|
||||||
|
if let Ok(platform) = self.inner.references()
|
||||||
|
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
|
||||||
|
&& refs.next().is_some()
|
||||||
|
{
|
||||||
|
signals.nip34_state_refs = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !signals.any() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = if signals.nip34_json
|
||||||
|
|| signals.nostr_repo_config
|
||||||
|
|| signals.nip34_grasp_remote
|
||||||
|
|| signals.nip34_state_refs
|
||||||
|
{
|
||||||
|
Nip34Kind::Initialized
|
||||||
|
} else if signals.nostr_remote {
|
||||||
|
Nip34Kind::Cloned
|
||||||
|
} else {
|
||||||
|
Nip34Kind::ToolingOnly
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Nip34Binding {
|
||||||
|
kind,
|
||||||
|
signals,
|
||||||
|
owner,
|
||||||
|
identifier,
|
||||||
|
grasp_urls,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
|
pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> {
|
||||||
if let Ok(platform) = repo.references()
|
self.edit_local_config(|config| {
|
||||||
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
|
config.set_raw_value("nostr.repo", naddr)?;
|
||||||
&& refs.next().is_some()
|
Ok(())
|
||||||
{
|
})
|
||||||
signals.nip34_state_refs = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !signals.any() {
|
// Mirrors `nak`'s `IsGraspURL`: two path segments,
|
||||||
return None;
|
// a path of at least 65 bytes, and a first segment that decodes as an `npub`.
|
||||||
|
fn is_grasp_url(url: &str) -> bool {
|
||||||
|
let Ok(parsed) = Url::parse(url) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = parsed.path();
|
||||||
|
if path.matches('/').count() != 2 || path.len() < 65 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::grasp_parts(url).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
let kind = if signals.nip34_json
|
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
|
||||||
|| signals.nostr_repo_config
|
let parsed = Url::parse(url).ok()?;
|
||||||
|| signals.nip34_grasp_remote
|
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
|
||||||
|| signals.nip34_state_refs
|
|
||||||
{
|
|
||||||
Nip34Kind::Initialized
|
|
||||||
} else if signals.nostr_remote {
|
|
||||||
Nip34Kind::Cloned
|
|
||||||
} else {
|
|
||||||
Nip34Kind::ToolingOnly
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(Nip34Binding {
|
let owner = PublicKey::parse(segments.next()?).ok()?;
|
||||||
kind,
|
let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?;
|
||||||
signals,
|
|
||||||
owner,
|
|
||||||
identifier,
|
|
||||||
grasp_urls,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config.
|
Some((owner, identifier))
|
||||||
pub fn set_nostr_repo(repo_path: &Path, naddr: &str) -> Result<()> {
|
|
||||||
let repo = gix::open(repo_path)?;
|
|
||||||
|
|
||||||
crate::remote::edit_local_config(&repo, |config| {
|
|
||||||
config.set_raw_value("nostr.repo", naddr)?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes,
|
|
||||||
/// and a first segment that decodes as an `npub`.
|
|
||||||
pub fn is_grasp_url(url: &str) -> bool {
|
|
||||||
let Ok(parsed) = Url::parse(url) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = parsed.path();
|
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
|
||||||
if path.matches('/').count() != 2 || path.len() < 65 {
|
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
|
||||||
return false;
|
if coordinate.kind != Kind::GitRepoAnnouncement {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let identifier = Self::non_empty(coordinate.identifier.clone())?;
|
||||||
|
Some((coordinate.public_key, identifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
grasp_parts(url).is_some()
|
// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
||||||
}
|
// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
||||||
|
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
||||||
|
let rest = url.strip_prefix("nostr://")?;
|
||||||
|
|
||||||
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
|
if rest.starts_with("naddr1") {
|
||||||
let parsed = Url::parse(url).ok()?;
|
return Self::coordinate_from_naddr(rest);
|
||||||
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
|
}
|
||||||
|
|
||||||
let owner = PublicKey::parse(segments.next()?).ok()?;
|
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
|
||||||
let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?;
|
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
|
||||||
|
|
||||||
Some((owner, identifier))
|
if parts
|
||||||
}
|
.first()
|
||||||
|
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
|
||||||
|
{
|
||||||
|
parts.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
|
if parts.len() < 2 {
|
||||||
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
|
return None;
|
||||||
if coordinate.kind != Kind::GitRepoAnnouncement {
|
}
|
||||||
return None;
|
|
||||||
|
let owner = PublicKey::parse(parts[0]).ok()?;
|
||||||
|
let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?;
|
||||||
|
|
||||||
|
Some((owner, identifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
let identifier = non_empty(coordinate.identifier.clone())?;
|
fn non_empty(value: impl Into<String>) -> Option<String> {
|
||||||
Some((coordinate.public_key, identifier))
|
let value = value.into();
|
||||||
}
|
(!value.is_empty()).then_some(value)
|
||||||
|
|
||||||
/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
|
||||||
/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
|
||||||
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
|
||||||
let rest = url.strip_prefix("nostr://")?;
|
|
||||||
|
|
||||||
if rest.starts_with("naddr1") {
|
|
||||||
return coordinate_from_naddr(rest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
|
|
||||||
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
|
|
||||||
|
|
||||||
if parts
|
|
||||||
.first()
|
|
||||||
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
|
|
||||||
{
|
|
||||||
parts.remove(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `[owner, (relay), identifier]`.
|
|
||||||
if parts.len() < 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let owner = PublicKey::parse(parts[0]).ok()?;
|
|
||||||
let identifier = non_empty(parts.last()?.trim_end_matches(".git"))?;
|
|
||||||
|
|
||||||
Some((owner, identifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn non_empty(value: impl Into<String>) -> Option<String> {
|
|
||||||
let value = value.into();
|
|
||||||
(!value.is_empty()).then_some(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -306,10 +305,14 @@ mod tests {
|
|||||||
.expect("naddr")
|
.expect("naddr")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn binding_of(path: &Path) -> Option<Nip34Binding> {
|
||||||
|
Repo::open(path).ok()?.nip34_binding()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plain_repository_has_no_binding() {
|
fn plain_repository_has_no_binding() {
|
||||||
let (_dir, path) = init_repo();
|
let (_dir, path) = init_repo();
|
||||||
assert!(detect_nip34(&path).is_none());
|
assert!(binding_of(&path).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -323,7 +326,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("write");
|
.expect("write");
|
||||||
|
|
||||||
let binding = detect_nip34(&path).expect("binding");
|
let binding = binding_of(&path).expect("binding");
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
assert!(binding.signals.nip34_json);
|
assert!(binding.signals.nip34_json);
|
||||||
assert_eq!(binding.owner, Some(owner));
|
assert_eq!(binding.owner, Some(owner));
|
||||||
@@ -335,7 +338,7 @@ mod tests {
|
|||||||
let (_dir, path) = init_repo();
|
let (_dir, path) = init_repo();
|
||||||
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
|
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
|
||||||
|
|
||||||
assert!(detect_nip34(&path).is_none());
|
assert!(binding_of(&path).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -351,7 +354,7 @@ mod tests {
|
|||||||
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
|
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
|
||||||
);
|
);
|
||||||
|
|
||||||
let binding = detect_nip34(&path).expect("binding");
|
let binding = binding_of(&path).expect("binding");
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
assert!(binding.signals.nip34_excluded);
|
assert!(binding.signals.nip34_excluded);
|
||||||
assert!(binding.signals.nip34_state_refs);
|
assert!(binding.signals.nip34_state_refs);
|
||||||
@@ -364,7 +367,7 @@ mod tests {
|
|||||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||||
git(&path, &["config", "nostr.repo", &naddr]);
|
git(&path, &["config", "nostr.repo", &naddr]);
|
||||||
|
|
||||||
let binding = detect_nip34(&path).expect("binding");
|
let binding = binding_of(&path).expect("binding");
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
assert!(binding.signals.nostr_repo_config);
|
assert!(binding.signals.nostr_repo_config);
|
||||||
assert_eq!(binding.owner, Some(owner));
|
assert_eq!(binding.owner, Some(owner));
|
||||||
@@ -377,9 +380,12 @@ mod tests {
|
|||||||
let owner = key();
|
let owner = key();
|
||||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||||
|
|
||||||
set_nostr_repo(&path, &naddr).expect("write marker");
|
Repo::open(&path)
|
||||||
|
.expect("open")
|
||||||
|
.set_nostr_repo(&naddr)
|
||||||
|
.expect("write marker");
|
||||||
|
|
||||||
let binding = detect_nip34(&path).expect("binding");
|
let binding = binding_of(&path).expect("binding");
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
assert!(binding.signals.nostr_repo_config);
|
assert!(binding.signals.nostr_repo_config);
|
||||||
assert_eq!(binding.owner, Some(owner));
|
assert_eq!(binding.owner, Some(owner));
|
||||||
@@ -394,7 +400,7 @@ mod tests {
|
|||||||
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
|
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
|
||||||
git(&path, &["remote", "add", "origin", &url]);
|
git(&path, &["remote", "add", "origin", &url]);
|
||||||
|
|
||||||
let binding = detect_nip34(&path).expect("binding");
|
let binding = binding_of(&path).expect("binding");
|
||||||
assert_eq!(binding.kind, Nip34Kind::Cloned);
|
assert_eq!(binding.kind, Nip34Kind::Cloned);
|
||||||
assert!(binding.signals.nostr_remote);
|
assert!(binding.signals.nostr_remote);
|
||||||
assert_eq!(binding.owner, Some(owner));
|
assert_eq!(binding.owner, Some(owner));
|
||||||
@@ -412,7 +418,7 @@ mod tests {
|
|||||||
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
|
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
|
||||||
);
|
);
|
||||||
|
|
||||||
let binding = detect_nip34(&path).expect("binding");
|
let binding = binding_of(&path).expect("binding");
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
assert!(binding.signals.nip34_grasp_remote);
|
assert!(binding.signals.nip34_grasp_remote);
|
||||||
assert!(binding.signals.grasp_remote);
|
assert!(binding.signals.grasp_remote);
|
||||||
@@ -426,7 +432,7 @@ mod tests {
|
|||||||
let (_dir, path) = init_repo();
|
let (_dir, path) = init_repo();
|
||||||
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
|
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
|
||||||
|
|
||||||
let binding = detect_nip34(&path).expect("binding");
|
let binding = binding_of(&path).expect("binding");
|
||||||
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
|
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
|
||||||
assert!(binding.signals.nostr_cache);
|
assert!(binding.signals.nostr_cache);
|
||||||
}
|
}
|
||||||
@@ -436,18 +442,18 @@ mod tests {
|
|||||||
let owner = key();
|
let owner = key();
|
||||||
let npub = owner.to_bech32().expect("npub");
|
let npub = owner.to_bech32().expect("npub");
|
||||||
|
|
||||||
assert!(is_grasp_url(&format!(
|
assert!(Repo::is_grasp_url(&format!(
|
||||||
"https://gitnostr.com/{npub}/my-repo.git"
|
"https://gitnostr.com/{npub}/my-repo.git"
|
||||||
)));
|
)));
|
||||||
assert!(is_grasp_url(&format!(
|
assert!(Repo::is_grasp_url(&format!(
|
||||||
"grasp://gitnostr.com/{npub}/my-repo.git"
|
"grasp://gitnostr.com/{npub}/my-repo.git"
|
||||||
)));
|
)));
|
||||||
|
|
||||||
assert!(!is_grasp_url("https://gitnostr.com/my-repo.git"));
|
assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git"));
|
||||||
assert!(!is_grasp_url(
|
assert!(!Repo::is_grasp_url(
|
||||||
"https://gitnostr.com/not-a-pubkey/my-repo.git"
|
"https://gitnostr.com/not-a-pubkey/my-repo.git"
|
||||||
));
|
));
|
||||||
assert!(!is_grasp_url(&format!(
|
assert!(!Repo::is_grasp_url(&format!(
|
||||||
"ssh://gitnostr.com/{npub}/my-repo.git"
|
"ssh://gitnostr.com/{npub}/my-repo.git"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|||||||
+304
-278
@@ -1,6 +1,5 @@
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::process::Stdio;
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||||
@@ -8,315 +7,342 @@ use diffy::{Hunk, Line};
|
|||||||
|
|
||||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||||
use crate::history::FileCommit;
|
use crate::history::FileCommit;
|
||||||
|
use crate::repo::Repo;
|
||||||
|
|
||||||
/// Apply a `git format-patch` patch or series with `git am`.
|
pub struct PatchParser;
|
||||||
///
|
|
||||||
/// Uses the git CLI because it handles the mbox format natively.
|
|
||||||
///
|
|
||||||
/// TODO: replace with a pure-Rust implementation later without changing callers.
|
|
||||||
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
|
||||||
let mut child = Command::new("git")
|
|
||||||
.arg("am")
|
|
||||||
.current_dir(repo_path)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.context("failed to spawn `git am`")?;
|
|
||||||
|
|
||||||
child
|
impl PatchParser {
|
||||||
.stdin
|
// A malformed input yields one element covering it.
|
||||||
.as_mut()
|
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||||
.expect("stdin piped")
|
Self::envelopes(patch)
|
||||||
.write_all(patch.as_bytes())?;
|
.into_iter()
|
||||||
|
.map(|message| message.text)
|
||||||
let output = child.wait_with_output()?;
|
.collect()
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
// Backed by `diffy::patch_set`, which implements git's extended diff format:
|
||||||
}
|
// rename and copy detection, binary detection, quoted/escaped paths.
|
||||||
|
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||||
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
|
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||||
/// Fails when the range has no commits.
|
return Ok(CommitDiff { files: Vec::new() });
|
||||||
///
|
|
||||||
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
|
|
||||||
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(repo_path)
|
|
||||||
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
|
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.output()
|
|
||||||
.context("failed to spawn `git format-patch`")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"git format-patch failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
||||||
|
|
||||||
if patch.trim().is_empty() {
|
|
||||||
bail!("no commits between {base} and {tip}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a `git format-patch` series into its individual patches, mbox messages.
|
|
||||||
///
|
|
||||||
/// A single patch yields one element.
|
|
||||||
/// A malformed input yields one element covering it.
|
|
||||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
|
||||||
let mut starts = vec![0usize];
|
|
||||||
let mut search_from = 1;
|
|
||||||
|
|
||||||
while let Some(rel) = patch[search_from..].find("\nFrom ") {
|
|
||||||
let ix = search_from + rel + 1;
|
|
||||||
let hex = patch[ix + 5..]
|
|
||||||
.split(|c: char| !c.is_ascii_hexdigit())
|
|
||||||
.next()
|
|
||||||
.unwrap_or("");
|
|
||||||
if hex.len() == 40 {
|
|
||||||
starts.push(ix);
|
|
||||||
}
|
}
|
||||||
search_from = ix + 1;
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||||
|
files.push(Self::file_diff(file?)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CommitDiff { files })
|
||||||
}
|
}
|
||||||
|
|
||||||
starts
|
// Entries appear in patch order, oldest first as `git format-patch`
|
||||||
.iter()
|
// produces them.
|
||||||
.enumerate()
|
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||||
.map(|(i, &start)| {
|
Self::envelopes(patch)
|
||||||
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
|
.into_iter()
|
||||||
&patch[start..end]
|
.filter(|message| !message.id.is_empty())
|
||||||
|
.map(|message| FileCommit {
|
||||||
|
id: message.id.to_string(),
|
||||||
|
summary: message
|
||||||
|
.header("Subject")
|
||||||
|
.map(Self::strip_patch_prefix)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
description: None,
|
||||||
|
author: message
|
||||||
|
.header("From")
|
||||||
|
.map(Self::name_from_address)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
time: message
|
||||||
|
.header("Date")
|
||||||
|
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
||||||
|
.map(|time| time.seconds)
|
||||||
|
.unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one parser `split_patch_series` and `patch_commits` share.
|
||||||
|
// A malformed input yields one message covering the whole input.
|
||||||
|
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||||
|
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||||
|
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||||
|
let mut headers_closed = false;
|
||||||
|
|
||||||
|
let mut offset = 0usize;
|
||||||
|
for line in patch.lines() {
|
||||||
|
let line_start = offset;
|
||||||
|
offset += line.len() + 1;
|
||||||
|
|
||||||
|
let is_envelope = line
|
||||||
|
.strip_prefix("From ")
|
||||||
|
.and_then(|rest| rest.split_whitespace().next())
|
||||||
|
.is_some_and(|id| id.len() == 40);
|
||||||
|
|
||||||
|
if is_envelope {
|
||||||
|
if let Some((start, id, headers)) = current.take() {
|
||||||
|
messages.push(Envelope {
|
||||||
|
text: &patch[start..],
|
||||||
|
id,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = line
|
||||||
|
.strip_prefix("From ")
|
||||||
|
.and_then(|rest| rest.split_whitespace().next())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
current = Some((line_start, id, Vec::new()));
|
||||||
|
headers_closed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((_, _, headers)) = &mut current {
|
||||||
|
if headers_closed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.is_empty() {
|
||||||
|
headers_closed = true;
|
||||||
|
} else {
|
||||||
|
headers.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((start, id, headers)) = current.take() {
|
||||||
|
messages.push(Envelope {
|
||||||
|
text: &patch[start..],
|
||||||
|
id,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages.is_empty() {
|
||||||
|
messages.push(Envelope {
|
||||||
|
text: patch,
|
||||||
|
id: "",
|
||||||
|
headers: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_from_address(from: &str) -> String {
|
||||||
|
match from.trim().find('<') {
|
||||||
|
Some(ix) => from[..ix].trim().to_string(),
|
||||||
|
None => from.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
|
||||||
|
fn strip_patch_prefix(subject: &str) -> String {
|
||||||
|
let trimmed = subject.trim();
|
||||||
|
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
let Some(end) = rest.find(']') else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||||
|
rest[end + 1..].trim().to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||||
|
// The `---`/`+++` paths carry the `a/`/`b/` prefix, dropped the same way
|
||||||
|
// `git apply -p1` does; rename and copy paths come from their own
|
||||||
|
// headers, unprefixed.
|
||||||
|
let stripped;
|
||||||
|
let operation = match file.operation() {
|
||||||
|
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||||
|
operation => {
|
||||||
|
stripped = operation.strip_prefix(1);
|
||||||
|
&stripped
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (path, old_path, status) = match operation {
|
||||||
|
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
||||||
|
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
||||||
|
FileOperation::Modify { modified, .. } => {
|
||||||
|
(modified.as_ref(), None, DiffStatus::Modified)
|
||||||
|
}
|
||||||
|
FileOperation::Rename { from, to } => {
|
||||||
|
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
||||||
|
}
|
||||||
|
FileOperation::Copy { from, to } => {
|
||||||
|
(to.as_ref(), Some(from.as_ref()), DiffStatus::Copied)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut insertions = 0usize;
|
||||||
|
let mut deletions = 0usize;
|
||||||
|
let mut hunks = Vec::new();
|
||||||
|
|
||||||
|
let patch = file.patch();
|
||||||
|
|
||||||
|
if let Some(text) = patch.as_text() {
|
||||||
|
for hunk in text.hunks() {
|
||||||
|
let hunk = Self::hunk_diff(hunk);
|
||||||
|
insertions += hunk
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|line| line.kind == DiffLineKind::Addition)
|
||||||
|
.count();
|
||||||
|
deletions += hunk
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|line| line.kind == DiffLineKind::Deletion)
|
||||||
|
.count();
|
||||||
|
hunks.push(hunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FileDiff {
|
||||||
|
path: path.to_owned(),
|
||||||
|
old_path: old_path.map(str::to_owned),
|
||||||
|
status,
|
||||||
|
insertions,
|
||||||
|
deletions,
|
||||||
|
binary: patch.is_binary(),
|
||||||
|
hunks,
|
||||||
})
|
})
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse `git format-patch` output, a single patch or a series.
|
|
||||||
///
|
|
||||||
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
|
|
||||||
/// `diff --git` headers, rename and copy detection, binary detection, and
|
|
||||||
/// C-style quoted or octal-escaped paths.
|
|
||||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
|
||||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
|
||||||
return Ok(CommitDiff { files: Vec::new() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut files = Vec::new();
|
// `diffy` reports only the hunk header ranges; the per-line numbers are
|
||||||
|
// counted from them the way the header encodes them.
|
||||||
|
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||||
|
let old_range = hunk.old_range();
|
||||||
|
let new_range = hunk.new_range();
|
||||||
|
|
||||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
let mut old = old_range.start() as u32;
|
||||||
files.push(file_diff(file?)?);
|
let mut new = new_range.start() as u32;
|
||||||
}
|
let mut lines = Vec::with_capacity(hunk.lines().len());
|
||||||
|
|
||||||
Ok(CommitDiff { files })
|
for line in hunk.lines() {
|
||||||
}
|
let (kind, text) = match line {
|
||||||
|
Line::Context(text) => (DiffLineKind::Context, *text),
|
||||||
|
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
||||||
|
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||||
|
};
|
||||||
|
|
||||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
let (old_no, new_no) = match kind {
|
||||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
DiffLineKind::Context => {
|
||||||
// component is dropped, the same way `git apply -p1` does.
|
let numbers = (Some(old), Some(new));
|
||||||
// Rename and copy paths come from their own headers, unprefixed.
|
old += 1;
|
||||||
let stripped;
|
new += 1;
|
||||||
let operation = match file.operation() {
|
numbers
|
||||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
}
|
||||||
operation => {
|
DiffLineKind::Addition => {
|
||||||
stripped = operation.strip_prefix(1);
|
let number = Some(new);
|
||||||
&stripped
|
new += 1;
|
||||||
|
(None, number)
|
||||||
|
}
|
||||||
|
DiffLineKind::Deletion => {
|
||||||
|
let number = Some(old);
|
||||||
|
old += 1;
|
||||||
|
(number, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(DiffLine {
|
||||||
|
kind,
|
||||||
|
old: old_no,
|
||||||
|
new: new_no,
|
||||||
|
text: Self::line_text(text),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let (path, old_path, status) = match operation {
|
DiffHunk {
|
||||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
old_start: old_range.start() as u32,
|
||||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
old_lines: old_range.len() as u32,
|
||||||
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
|
new_start: new_range.start() as u32,
|
||||||
FileOperation::Rename { from, to } => {
|
new_lines: new_range.len() as u32,
|
||||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
lines,
|
||||||
}
|
|
||||||
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut insertions = 0usize;
|
|
||||||
let mut deletions = 0usize;
|
|
||||||
let mut hunks = Vec::new();
|
|
||||||
|
|
||||||
let patch = file.patch();
|
|
||||||
|
|
||||||
if let Some(text) = patch.as_text() {
|
|
||||||
for hunk in text.hunks() {
|
|
||||||
let hunk = hunk_diff(hunk);
|
|
||||||
insertions += hunk
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.filter(|line| line.kind == DiffLineKind::Addition)
|
|
||||||
.count();
|
|
||||||
deletions += hunk
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.filter(|line| line.kind == DiffLineKind::Deletion)
|
|
||||||
.count();
|
|
||||||
hunks.push(hunk);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(FileDiff {
|
fn line_text(text: &str) -> String {
|
||||||
path: path.to_owned(),
|
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||||
old_path: old_path.map(str::to_owned),
|
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||||
status,
|
|
||||||
insertions,
|
|
||||||
deletions,
|
|
||||||
binary: patch.is_binary(),
|
|
||||||
hunks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
|
|
||||||
///
|
|
||||||
/// `diffy` reports only the hunk header ranges. The per-line numbers are
|
|
||||||
/// counted from them the way the header encodes them: context lines advance
|
|
||||||
/// both sides, deletions only the old, insertions only the new.
|
|
||||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
|
||||||
let old_range = hunk.old_range();
|
|
||||||
let new_range = hunk.new_range();
|
|
||||||
|
|
||||||
let mut old = old_range.start() as u32;
|
|
||||||
let mut new = new_range.start() as u32;
|
|
||||||
let mut lines = Vec::with_capacity(hunk.lines().len());
|
|
||||||
|
|
||||||
for line in hunk.lines() {
|
|
||||||
let (kind, text) = match line {
|
|
||||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
|
||||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
|
||||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
|
||||||
};
|
|
||||||
|
|
||||||
let (old_no, new_no) = match kind {
|
|
||||||
DiffLineKind::Context => {
|
|
||||||
let numbers = (Some(old), Some(new));
|
|
||||||
old += 1;
|
|
||||||
new += 1;
|
|
||||||
numbers
|
|
||||||
}
|
|
||||||
DiffLineKind::Addition => {
|
|
||||||
let number = Some(new);
|
|
||||||
new += 1;
|
|
||||||
(None, number)
|
|
||||||
}
|
|
||||||
DiffLineKind::Deletion => {
|
|
||||||
let number = Some(old);
|
|
||||||
old += 1;
|
|
||||||
(number, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(DiffLine {
|
|
||||||
kind,
|
|
||||||
old: old_no,
|
|
||||||
new: new_no,
|
|
||||||
text: line_text(text),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
DiffHunk {
|
|
||||||
old_start: old_range.start() as u32,
|
|
||||||
old_lines: old_range.len() as u32,
|
|
||||||
new_start: new_range.start() as u32,
|
|
||||||
new_lines: new_range.len() as u32,
|
|
||||||
lines,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The content of a parsed line without its line ending.
|
// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
|
||||||
///
|
struct Envelope<'a> {
|
||||||
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
|
text: &'a str,
|
||||||
fn line_text(text: &str) -> String {
|
id: &'a str,
|
||||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
headers: Vec<&'a str>,
|
||||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits of a `git format-patch` output, a single patch or a series.
|
impl Envelope<'_> {
|
||||||
///
|
fn header(&self, name: &str) -> Option<&str> {
|
||||||
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
let prefix = format!("{name}: ");
|
||||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
self.headers
|
||||||
let lines: Vec<&str> = patch.lines().collect();
|
.iter()
|
||||||
|
.find(|line| line.starts_with(&prefix))
|
||||||
|
.map(|line| &line[prefix.len()..])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut commits = Vec::new();
|
impl Repo {
|
||||||
let mut i = 0;
|
// The git CLI handles the mbox format natively.
|
||||||
|
// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||||
|
pub fn apply_patch(&self, patch: &str) -> Result<()> {
|
||||||
|
let workdir = self
|
||||||
|
.inner
|
||||||
|
.workdir()
|
||||||
|
.context("repository has no worktree")?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
while i < lines.len() {
|
let mut child = Repo::git_command(&workdir)
|
||||||
// A patch starts with its `From <id> <date>` envelope line.
|
.args(["am"])
|
||||||
let Some(rest) = lines[i].strip_prefix("From ") else {
|
.stdin(Stdio::piped())
|
||||||
i += 1;
|
.stderr(Stdio::piped())
|
||||||
continue;
|
.spawn()
|
||||||
};
|
.context("failed to spawn `git am`")?;
|
||||||
|
|
||||||
let Some(id) = rest.split_whitespace().next() else {
|
child
|
||||||
i += 1;
|
.stdin
|
||||||
continue;
|
.as_mut()
|
||||||
};
|
.context("git am has no stdin pipe")?
|
||||||
|
.write_all(patch.as_bytes())?;
|
||||||
|
|
||||||
if id.len() != 40 {
|
let output = child.wait_with_output()?;
|
||||||
i += 1;
|
|
||||||
continue;
|
if !output.status.success() {
|
||||||
|
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut author = String::new();
|
Ok(())
|
||||||
let mut summary = String::new();
|
}
|
||||||
let mut time = 0i64;
|
|
||||||
|
|
||||||
// Envelope headers run up to the blank line before the commit message.
|
// Fails when the range has no commits. The mbox is returned untrimmed;
|
||||||
i += 1;
|
// trailing newlines are part of the format.
|
||||||
while i < lines.len() && !lines[i].is_empty() {
|
pub fn format_patch_between(&self, base: &str, tip: &str) -> Result<String> {
|
||||||
let header = lines[i];
|
let output = Repo::run_git(
|
||||||
if let Some(value) = header.strip_prefix("From: ") {
|
self.workdir_or_dot(),
|
||||||
author = name_from_address(value);
|
&["format-patch", "--stdout", &format!("{base}..{tip}")],
|
||||||
} else if let Some(value) = header.strip_prefix("Subject: ") {
|
"git format-patch",
|
||||||
summary = strip_patch_prefix(value);
|
)?;
|
||||||
} else if let Some(value) = header.strip_prefix("Date: ") {
|
|
||||||
time = gix::date::parse(value.trim(), None)
|
if !output.status.success() {
|
||||||
.map(|t| t.seconds)
|
bail!(
|
||||||
.unwrap_or(0);
|
"git format-patch failed: {}",
|
||||||
}
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
i += 1;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
commits.push(FileCommit {
|
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||||
id: id.to_string(),
|
|
||||||
summary,
|
|
||||||
description: None,
|
|
||||||
author,
|
|
||||||
time,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
commits
|
if patch.trim().is_empty() {
|
||||||
}
|
bail!("no commits between {base} and {tip}");
|
||||||
|
}
|
||||||
|
|
||||||
fn name_from_address(from: &str) -> String {
|
Ok(patch)
|
||||||
match from.trim().find('<') {
|
|
||||||
Some(ix) => from[..ix].trim().to_string(),
|
|
||||||
None => from.trim().to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip the patch prefix from a `Subject:` header.
|
|
||||||
///
|
|
||||||
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
|
||||||
fn strip_patch_prefix(subject: &str) -> String {
|
|
||||||
let trimmed = subject.trim();
|
|
||||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
|
||||||
return trimmed.to_string();
|
|
||||||
};
|
|
||||||
let Some(end) = rest.find(']') else {
|
|
||||||
return trimmed.to_string();
|
|
||||||
};
|
|
||||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
|
||||||
rest[end + 1..].trim().to_string()
|
|
||||||
} else {
|
|
||||||
trimmed.to_string()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-337
@@ -6,346 +6,276 @@ use anyhow::{Context, Result, bail};
|
|||||||
use gix::interrupt::IS_INTERRUPTED;
|
use gix::interrupt::IS_INTERRUPTED;
|
||||||
use gix::progress::Discard;
|
use gix::progress::Discard;
|
||||||
|
|
||||||
/// Clone into `path` from the first working URL in `clone_urls`.
|
use crate::repo::Repo;
|
||||||
///
|
|
||||||
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
|
||||||
pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
|
|
||||||
if path.exists() {
|
|
||||||
bail!("destination {} already exists", path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
try_each_url(clone_urls, "clone", |url| {
|
impl Repo {
|
||||||
let repo = clone(url, path)?;
|
pub fn fetch(&self) -> Result<()> {
|
||||||
// The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs.
|
|
||||||
fetch_all(&repo).ok();
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
|
||||||
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
|
||||||
let options = gix::remote::ref_map::Options {
|
|
||||||
extra_refspecs: vec![
|
|
||||||
gix::refspec::parse(
|
|
||||||
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
|
|
||||||
gix::refspec::parse::Operation::Fetch,
|
|
||||||
)?
|
|
||||||
.to_owned(),
|
|
||||||
],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
repo.find_remote("origin")?
|
|
||||||
.connect(gix::remote::Direction::Fetch)?
|
|
||||||
.prepare_fetch(Discard, options)?
|
|
||||||
.receive(Discard, &IS_INTERRUPTED)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(repo_path)
|
|
||||||
.args(["push"])
|
|
||||||
.arg(url)
|
|
||||||
.arg(format!("{commit}:{reference}"))
|
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.output()
|
|
||||||
.context("failed to spawn `git push`")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"git push failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
|
||||||
///
|
|
||||||
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
|
||||||
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
|
||||||
fn transport_url(url: &str) -> String {
|
|
||||||
url.strip_prefix("grasp://")
|
|
||||||
.map(|rest| format!("https://{rest}"))
|
|
||||||
.unwrap_or_else(|| url.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `attempt` against each URL in `urls` until one succeeds.
|
|
||||||
///
|
|
||||||
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
|
||||||
/// or `no clone URLs provided` when the list is empty.
|
|
||||||
fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
|
|
||||||
where
|
|
||||||
F: FnMut(&str) -> Result<()>,
|
|
||||||
{
|
|
||||||
let mut last_err: Option<anyhow::Error> = None;
|
|
||||||
|
|
||||||
for url in urls {
|
|
||||||
match attempt(url.as_ref()) {
|
|
||||||
Ok(()) => return Ok(()),
|
|
||||||
Err(e) => last_err = Some(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match last_err {
|
|
||||||
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
|
|
||||||
None => bail!("no clone URLs provided"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
|
||||||
let url = transport_url(url);
|
|
||||||
let url = gix::url::parse(url).context("invalid clone URL")?;
|
|
||||||
|
|
||||||
let mut prepare = gix::prepare_clone(url, path)?;
|
|
||||||
let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?;
|
|
||||||
let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?;
|
|
||||||
|
|
||||||
Ok(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
|
||||||
push_refspecs(
|
|
||||||
repo_path,
|
|
||||||
base_url,
|
|
||||||
owner,
|
|
||||||
repo_id,
|
|
||||||
&["refs/heads/main:refs/heads/main"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
|
||||||
///
|
|
||||||
/// This mirrors an initialized repository's whole history.
|
|
||||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
|
||||||
push_refspecs(
|
|
||||||
repo_path,
|
|
||||||
base_url,
|
|
||||||
owner,
|
|
||||||
repo_id,
|
|
||||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_refspecs(
|
|
||||||
repo_path: &Path,
|
|
||||||
base_url: &str,
|
|
||||||
owner: &str,
|
|
||||||
repo_id: &str,
|
|
||||||
refspecs: &[&str],
|
|
||||||
) -> Result<()> {
|
|
||||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
|
||||||
|
|
||||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
|
||||||
args.push("push");
|
|
||||||
args.push(&url);
|
|
||||||
args.extend_from_slice(refspecs);
|
|
||||||
|
|
||||||
let output = git_output(repo_path, &args, "git push")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"git push to {base_url} failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `url` advertises every ref in `expected` at the given commit.
|
|
||||||
///
|
|
||||||
/// Extra advertised refs are ignored: the question is whether the data this
|
|
||||||
/// push wanted to land is already there, not whether the remote is an exact mirror.
|
|
||||||
/// This is the convergence probe for a push that lost the compare-and-swap race
|
|
||||||
/// to the grasp server's own background ref alignment.
|
|
||||||
pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
|
||||||
if expected.is_empty() {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let repo = gix::open(repo_path)?;
|
|
||||||
let url = transport_url(url);
|
|
||||||
|
|
||||||
// A URL-created remote has no configured fetch refspecs, and `ref_map` only
|
|
||||||
// keeps refs that match one. Match each expected ref by its exact name,
|
|
||||||
// like `git ls-remote <url> <name>` would; ref maps never write to the repository.
|
|
||||||
let refspecs = expected
|
|
||||||
.iter()
|
|
||||||
.map(|(name, _)| {
|
|
||||||
gix::refspec::parse(
|
|
||||||
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
|
||||||
gix::refspec::parse::Operation::Fetch,
|
|
||||||
)
|
|
||||||
.map(|spec| spec.to_owned())
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.context("invalid refspec")?;
|
|
||||||
|
|
||||||
let options = gix::remote::ref_map::Options {
|
|
||||||
extra_refspecs: refspecs,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let (refs, _) = repo
|
|
||||||
.remote_at(url.as_str())
|
|
||||||
.with_context(|| format!("cannot use remote {url}"))?
|
|
||||||
.connect(gix::remote::Direction::Fetch)
|
|
||||||
.with_context(|| format!("cannot connect to {url}"))?
|
|
||||||
.ref_map(Discard, options)
|
|
||||||
.with_context(|| format!("listing refs of {url} failed"))?;
|
|
||||||
|
|
||||||
// Peeled tag entries carry the tag object in their direct oid, so mapping
|
|
||||||
// each advertised ref to its direct oid matches `git ls-remote` while
|
|
||||||
// skipping the duplicated `^{}` lines.
|
|
||||||
let advertised: HashMap<String, String> = refs
|
|
||||||
.remote_refs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|reference| {
|
|
||||||
let (name, object, _peeled) = reference.unpack();
|
|
||||||
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(expected
|
|
||||||
.iter()
|
|
||||||
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add `origin` pointing at `url` when the repository has no remote yet.
|
|
||||||
///
|
|
||||||
/// No-op if `origin` already exists.
|
|
||||||
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
|
||||||
let repo = gix::open(repo_path)?;
|
|
||||||
if repo.find_remote("origin").is_ok() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// `git remote add` also configures the default fetch refspec.
|
|
||||||
edit_local_config(&repo, |config| {
|
|
||||||
config.set_raw_value("remote.origin.url", url)?;
|
|
||||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Point `origin` at `url`, replacing an existing remote,
|
|
||||||
/// used after a clone whose `origin` points at the cloned-from path.
|
|
||||||
///
|
|
||||||
/// A working copy cloned from a local mirror is re-targeted at the grasp server.
|
|
||||||
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
|
||||||
let repo = gix::open(repo_path)?;
|
|
||||||
let had_origin = repo.find_remote("origin").is_ok();
|
|
||||||
|
|
||||||
edit_local_config(&repo, |config| {
|
|
||||||
// Replaces the existing url, like `git remote set-url origin <url>`.
|
|
||||||
// A pre-existing fetch refspec is left untouched.
|
|
||||||
config.set_raw_value("remote.origin.url", url)?;
|
|
||||||
|
|
||||||
if !had_origin {
|
|
||||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply `edit` to the repository-local configuration and persist it.
|
|
||||||
pub(crate) fn edit_local_config(
|
|
||||||
repo: &gix::Repository,
|
|
||||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let config_path = repo.common_dir().join("config");
|
|
||||||
|
|
||||||
let mut lock = gix::lock::File::acquire_to_update_resource(
|
|
||||||
&config_path,
|
|
||||||
gix::lock::acquire::Fail::Immediately,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.context("failed to lock repository config")?;
|
|
||||||
|
|
||||||
let mut config =
|
|
||||||
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) {
|
|
||||||
Ok(config) => config,
|
|
||||||
// A repository without a config file yet starts from scratch.
|
|
||||||
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
|
|
||||||
if source.kind() == std::io::ErrorKind::NotFound =>
|
|
||||||
{
|
|
||||||
gix::config::File::default()
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error).context("failed to read repository config"),
|
|
||||||
};
|
|
||||||
|
|
||||||
edit(&mut config)?;
|
|
||||||
|
|
||||||
config
|
|
||||||
.write_to(&mut lock)
|
|
||||||
.context("failed to write repository config")?;
|
|
||||||
|
|
||||||
lock.commit().context("failed to save repository config")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch `refspec` into `repo_path` from the first working URL in `urls`.
|
|
||||||
/// When no URL works, the last error is returned.
|
|
||||||
///
|
|
||||||
/// Never touches the checked-out refs or the worktree.
|
|
||||||
pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
|
|
||||||
let repo = gix::open(repo_path)?;
|
|
||||||
let refspec = gix::refspec::parse(
|
|
||||||
gix::bstr::BStr::new(refspec),
|
|
||||||
gix::refspec::parse::Operation::Fetch,
|
|
||||||
)
|
|
||||||
.context("invalid fetch refspec")?
|
|
||||||
.to_owned();
|
|
||||||
|
|
||||||
try_each_url(urls, "fetch", |url| {
|
|
||||||
let url = transport_url(url);
|
|
||||||
let options = gix::remote::ref_map::Options {
|
let options = gix::remote::ref_map::Options {
|
||||||
extra_refspecs: vec![refspec.clone()],
|
extra_refspecs: vec![
|
||||||
|
gix::refspec::parse(
|
||||||
|
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
|
||||||
|
gix::refspec::parse::Operation::Fetch,
|
||||||
|
)?
|
||||||
|
.to_owned(),
|
||||||
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
repo.remote_at(url.as_str())
|
self.inner
|
||||||
.with_context(|| format!("fetch from {url} failed"))?
|
.find_remote("origin")?
|
||||||
.connect(gix::remote::Direction::Fetch)
|
.connect(gix::remote::Direction::Fetch)?
|
||||||
.with_context(|| format!("fetch from {url} failed"))?
|
.prepare_fetch(Discard, options)?
|
||||||
.prepare_fetch(Discard, options)
|
.receive(Discard, &IS_INTERRUPTED)?;
|
||||||
.with_context(|| format!("fetch from {url} failed"))?
|
|
||||||
.receive(Discard, &IS_INTERRUPTED)
|
|
||||||
.with_context(|| format!("fetch from {url} failed"))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||||
/// The URL of the `origin` remote of the repository at `workdir`.
|
let output = Self::run_git(
|
||||||
///
|
self.workdir_or_dot(),
|
||||||
/// `None` when it has no `origin` yet.
|
&["push", url, &format!("{commit}:{reference}")],
|
||||||
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
"git push",
|
||||||
let Ok(repo) = gix::open(workdir) else {
|
)?;
|
||||||
return Ok(None);
|
|
||||||
};
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
let Ok(remote) = repo.find_remote("origin") else {
|
"git push failed: {}",
|
||||||
return Ok(None);
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
};
|
);
|
||||||
|
}
|
||||||
Ok(remote
|
Ok(())
|
||||||
.url(gix::remote::Direction::Fetch)
|
}
|
||||||
.map(|url| url.to_string()))
|
|
||||||
}
|
pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
|
self.push_refspecs(
|
||||||
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
base_url,
|
||||||
///
|
owner,
|
||||||
/// `what` names the command in the spawn error.
|
repo_id,
|
||||||
pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
&["refs/heads/main:refs/heads/main"],
|
||||||
Command::new("git")
|
)
|
||||||
.arg("-C")
|
}
|
||||||
.arg(dir)
|
|
||||||
.args(args)
|
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
self.push_refspecs(
|
||||||
.stderr(Stdio::piped())
|
base_url,
|
||||||
.output()
|
owner,
|
||||||
.with_context(|| format!("failed to spawn `{what}`"))
|
repo_id,
|
||||||
|
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_refspecs(
|
||||||
|
&self,
|
||||||
|
base_url: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo_id: &str,
|
||||||
|
refspecs: &[&str],
|
||||||
|
) -> Result<()> {
|
||||||
|
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||||
|
|
||||||
|
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||||
|
args.push("push");
|
||||||
|
args.push(&url);
|
||||||
|
args.extend_from_slice(refspecs);
|
||||||
|
|
||||||
|
let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"git push to {base_url} failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The convergence probe for a push that lost the compare-and-swap race to
|
||||||
|
// the grasp server's own background ref alignment. Extra advertised refs
|
||||||
|
// are ignored: the question is whether the pushed data is already there,
|
||||||
|
// not whether the remote is an exact mirror.
|
||||||
|
pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||||
|
if expected.is_empty() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = Self::transport_url(url);
|
||||||
|
|
||||||
|
// A URL-created remote has no configured fetch refspecs, and `ref_map`
|
||||||
|
// only keeps refs matching one; match each expected ref by exact name.
|
||||||
|
let refspecs = expected
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| {
|
||||||
|
gix::refspec::parse(
|
||||||
|
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
||||||
|
gix::refspec::parse::Operation::Fetch,
|
||||||
|
)
|
||||||
|
.map(|spec| spec.to_owned())
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.context("invalid refspec")?;
|
||||||
|
|
||||||
|
let options = gix::remote::ref_map::Options {
|
||||||
|
extra_refspecs: refspecs,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (refs, _) = self
|
||||||
|
.inner
|
||||||
|
.remote_at(url.as_str())
|
||||||
|
.with_context(|| format!("cannot use remote {url}"))?
|
||||||
|
.connect(gix::remote::Direction::Fetch)
|
||||||
|
.with_context(|| format!("cannot connect to {url}"))?
|
||||||
|
.ref_map(Discard, options)
|
||||||
|
.with_context(|| format!("listing refs of {url} failed"))?;
|
||||||
|
|
||||||
|
// Peeled tag entries carry the tag object in their direct oid, matching
|
||||||
|
// `git ls-remote` while skipping the duplicated `^{}` lines.
|
||||||
|
let advertised: HashMap<String, String> = refs
|
||||||
|
.remote_refs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reference| {
|
||||||
|
let (name, object, _peeled) = reference.unpack();
|
||||||
|
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(expected
|
||||||
|
.iter()
|
||||||
|
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||||
|
if self.inner.find_remote("origin").is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// `git remote add` also configures the default fetch refspec.
|
||||||
|
self.edit_local_config(|config| {
|
||||||
|
config.set_raw_value("remote.origin.url", url)?;
|
||||||
|
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A working copy cloned from a local mirror is re-targeted at the
|
||||||
|
// grasp server; a pre-existing fetch refspec is left untouched.
|
||||||
|
pub fn set_origin(&self, url: &str) -> Result<()> {
|
||||||
|
let had_origin = self.inner.find_remote("origin").is_ok();
|
||||||
|
|
||||||
|
self.edit_local_config(|config| {
|
||||||
|
config.set_raw_value("remote.origin.url", url)?;
|
||||||
|
|
||||||
|
if !had_origin {
|
||||||
|
config
|
||||||
|
.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn origin_url(&self) -> Result<Option<String>> {
|
||||||
|
let Ok(remote) = self.inner.find_remote("origin") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(remote
|
||||||
|
.url(gix::remote::Direction::Fetch)
|
||||||
|
.map(|url| url.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never touches the checked-out refs or the worktree. The last error is
|
||||||
|
// returned when no URL works.
|
||||||
|
pub fn fetch_refs<U: AsRef<str>>(&self, urls: &[U], refspec: &str) -> Result<()> {
|
||||||
|
let refspec = gix::refspec::parse(
|
||||||
|
gix::bstr::BStr::new(refspec),
|
||||||
|
gix::refspec::parse::Operation::Fetch,
|
||||||
|
)
|
||||||
|
.context("invalid fetch refspec")?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
let url = Self::transport_url(url.as_ref());
|
||||||
|
let outcome = (|| -> Result<()> {
|
||||||
|
let options = gix::remote::ref_map::Options {
|
||||||
|
extra_refspecs: vec![refspec.clone()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
self.inner
|
||||||
|
.remote_at(url.as_str())
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?
|
||||||
|
.connect(gix::remote::Direction::Fetch)
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?
|
||||||
|
.prepare_fetch(Discard, options)
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?
|
||||||
|
.receive(Discard, &IS_INTERRUPTED)
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(error) => last_error = Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match last_error {
|
||||||
|
Some(error) => Err(error).context("failed to fetch from any mirror"),
|
||||||
|
None => bail!("no fetch URLs provided"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_local_config(
|
||||||
|
&self,
|
||||||
|
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let config_path = self.inner.common_dir().join("config");
|
||||||
|
|
||||||
|
let mut lock = gix::lock::File::acquire_to_update_resource(
|
||||||
|
&config_path,
|
||||||
|
gix::lock::acquire::Fail::Immediately,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("failed to lock repository config")?;
|
||||||
|
|
||||||
|
let mut config =
|
||||||
|
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local)
|
||||||
|
{
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
|
||||||
|
if source.kind() == std::io::ErrorKind::NotFound =>
|
||||||
|
{
|
||||||
|
gix::config::File::default()
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error).context("failed to read repository config"),
|
||||||
|
};
|
||||||
|
|
||||||
|
edit(&mut config)?;
|
||||||
|
|
||||||
|
config
|
||||||
|
.write_to(&mut lock)
|
||||||
|
.context("failed to write repository config")?;
|
||||||
|
|
||||||
|
lock.commit().context("failed to save repository config")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn workdir_or_dot(&self) -> &Path {
|
||||||
|
self.inner.workdir().unwrap_or_else(|| Path::new("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||||
|
Self::git_command(dir)
|
||||||
|
.args(args)
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("failed to spawn `{what}`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_command(dir: &Path) -> Command {
|
||||||
|
let mut command = Command::new("git");
|
||||||
|
command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
command
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+427
-426
@@ -2,342 +2,397 @@ use std::path::Path;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use crate::history::open_with_cache;
|
// History walks re-decode the same commit objects without one; sized
|
||||||
use crate::worktree::{force_checkout, worktree_dirty};
|
// generously, a walk can cover a large portion of the history.
|
||||||
|
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
/// The merge base of two revisions in the repository at `repo_path`,
|
// One `open` per operation instead of every helper re-opening by path.
|
||||||
/// revisions may be branch names, remote-tracking refs or commit ids.
|
pub struct Repo {
|
||||||
///
|
pub(crate) inner: gix::Repository,
|
||||||
/// `Ok(None)` when the revisions share no common ancestor.
|
|
||||||
///
|
|
||||||
/// Unresolvable revisions are errors.
|
|
||||||
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
|
||||||
let repo = open_with_cache(repo_path)?;
|
|
||||||
let a = repo.rev_parse_single(a.as_bytes())?;
|
|
||||||
let b = repo.rev_parse_single(b.as_bytes())?;
|
|
||||||
match repo.merge_base(a, b) {
|
|
||||||
Ok(id) => Ok(Some(id.to_string())),
|
|
||||||
// No common ancestor, a valid outcome for a proposal.
|
|
||||||
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The commit HEAD points to in the repository at `repo_path`.
|
impl Repo {
|
||||||
///
|
pub fn open(workdir: &Path) -> Result<Self> {
|
||||||
/// `None` when the repository has no commits yet, an unborn HEAD.
|
Ok(Self {
|
||||||
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
inner: gix::open(workdir)?,
|
||||||
let Ok(repo) = gix::open(repo_path) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
match repo.head_id() {
|
|
||||||
Ok(id) => Ok(Some(id.to_string())),
|
|
||||||
Err(_) => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first.
|
|
||||||
/// This is the order `git am` creates them.
|
|
||||||
///
|
|
||||||
/// `HEAD` alone when `base` is `None`.
|
|
||||||
pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>> {
|
|
||||||
let repo = match gix::open(repo_path) {
|
|
||||||
Ok(repo) => repo,
|
|
||||||
Err(_) if base.is_none() => return Ok(Vec::new()),
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let head = match repo.head_id() {
|
|
||||||
Ok(head) => head,
|
|
||||||
Err(_) if base.is_none() => return Ok(Vec::new()),
|
|
||||||
Err(e) => return Err(e).context("repository has no commits"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(base) = base else {
|
|
||||||
return Ok(vec![head.to_string()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
let base = repo.rev_parse_single(base.as_bytes())?;
|
|
||||||
let mut commits = Vec::new();
|
|
||||||
|
|
||||||
for info in repo
|
|
||||||
.rev_walk([head])
|
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
|
||||||
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
|
||||||
))
|
|
||||||
.with_hidden([base])
|
|
||||||
.all()?
|
|
||||||
{
|
|
||||||
commits.push(info?.id().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oldest first, like `git rev-list --reverse`, the order `git am` creates them.
|
|
||||||
commits.reverse();
|
|
||||||
|
|
||||||
Ok(commits)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The identity written to reflogs and commits created by this crate itself.
|
|
||||||
///
|
|
||||||
/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works
|
|
||||||
/// without a global git identity, and `gix` runs no hooks and never signs.
|
|
||||||
pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) {
|
|
||||||
let seconds = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|duration| duration.as_secs() as i64)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let signature = gix::actor::Signature {
|
|
||||||
name: gix::bstr::BString::from("Signed"),
|
|
||||||
email: gix::bstr::BString::from("signed@localhost"),
|
|
||||||
time: gix::date::Time { seconds, offset: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
(signature, gix::date::parse::TimeBuf::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a repository at `path` with an initial `main` branch.
|
|
||||||
/// Write a `README.md` from `name` and `description`, then create the initial commit.
|
|
||||||
///
|
|
||||||
/// Returns the initial commit id.
|
|
||||||
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
|
|
||||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
|
||||||
|
|
||||||
std::fs::create_dir_all(path)
|
|
||||||
.with_context(|| format!("failed to create {}", path.display()))?;
|
|
||||||
|
|
||||||
let repo = gix::init(path)?;
|
|
||||||
|
|
||||||
let (signature, mut time_buf) = repository_signature();
|
|
||||||
let signature = signature.to_ref(&mut time_buf);
|
|
||||||
|
|
||||||
// The initial branch is `main`, regardless of `init.defaultBranch` in
|
|
||||||
// the user's git configuration: point the unborn HEAD there.
|
|
||||||
let head = gix::refs::FullName::try_from("HEAD")
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
|
||||||
|
|
||||||
repo.edit_references_as(
|
|
||||||
[RefEdit {
|
|
||||||
change: Change::Update {
|
|
||||||
log: LogChange {
|
|
||||||
mode: RefLog::AndReference,
|
|
||||||
force_create_reflog: false,
|
|
||||||
message: "checkout: moving to main".into(),
|
|
||||||
},
|
|
||||||
expected: PreviousValue::Any,
|
|
||||||
new: gix::refs::Target::Symbolic(
|
|
||||||
gix::refs::FullName::try_from("refs/heads/main")
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
name: head,
|
|
||||||
deref: false,
|
|
||||||
}],
|
|
||||||
Some(signature),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let readme = if description.trim().is_empty() {
|
|
||||||
format!("# {name}\n")
|
|
||||||
} else {
|
|
||||||
format!("# {name}\n\n{description}\n")
|
|
||||||
};
|
|
||||||
|
|
||||||
std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?;
|
|
||||||
|
|
||||||
let blob = repo.write_object(gix::objs::Blob {
|
|
||||||
data: readme.into_bytes(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let tree = repo.write_object(gix::objs::Tree {
|
|
||||||
entries: vec![gix::objs::tree::Entry {
|
|
||||||
mode: gix::objs::tree::EntryKind::Blob.into(),
|
|
||||||
filename: gix::bstr::BString::from("README.md"),
|
|
||||||
oid: blob.into(),
|
|
||||||
}],
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let commit = repo.commit_as(
|
|
||||||
signature,
|
|
||||||
signature,
|
|
||||||
"HEAD",
|
|
||||||
"Initial commit",
|
|
||||||
tree,
|
|
||||||
Vec::<gix::ObjectId>::new(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Populate the index so the fresh repository is clean,
|
|
||||||
// as `git add` and `git commit` would leave it.
|
|
||||||
let mut index = repo.index_from_tree(&tree)?;
|
|
||||||
index.write(gix::index::write::Options::default())?;
|
|
||||||
|
|
||||||
Ok(commit.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The earliest unique commit of the repository at `repo_path`.
|
|
||||||
/// Used as the NIP-34 announcement's `euc` marker.
|
|
||||||
///
|
|
||||||
/// `None` for a repository without commits.
|
|
||||||
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
|
||||||
let Ok(repo) = gix::open(repo_path) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(head) = repo.head_id() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
for info in repo
|
|
||||||
.rev_walk([head])
|
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
|
||||||
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
|
||||||
))
|
|
||||||
.all()?
|
|
||||||
{
|
|
||||||
let info = info?;
|
|
||||||
if info.parent_ids().next().is_none() {
|
|
||||||
return Ok(Some(info.id().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`.
|
|
||||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
|
||||||
///
|
|
||||||
/// Returns an empty list when nothing matches.
|
|
||||||
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
|
||||||
let pattern = prefix.trim_end_matches('/');
|
|
||||||
let repo = gix::open(repo_path)?;
|
|
||||||
let mut names = Vec::new();
|
|
||||||
|
|
||||||
for reference in repo.references()?.all()? {
|
|
||||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
||||||
let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned();
|
|
||||||
|
|
||||||
// Match the pattern itself and everything beneath it, like `git for-each-ref`.
|
|
||||||
let under_pattern = name
|
|
||||||
.strip_prefix(pattern)
|
|
||||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
|
||||||
|
|
||||||
if under_pattern {
|
|
||||||
names.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
names.sort();
|
|
||||||
|
|
||||||
Ok(names)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete every ref under `prefix` of the repository at `repo_path`.
|
|
||||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
|
||||||
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
|
||||||
use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
|
|
||||||
|
|
||||||
let refs = refs_with_prefix(repo_path, prefix)?;
|
|
||||||
if refs.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let repo = gix::open(repo_path)?;
|
|
||||||
let edits: Vec<RefEdit> = refs
|
|
||||||
.iter()
|
|
||||||
.map(|name| {
|
|
||||||
let full = gix::refs::FullName::try_from(name.as_str())
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?;
|
|
||||||
Ok(RefEdit {
|
|
||||||
change: Change::Delete {
|
|
||||||
expected: PreviousValue::Any,
|
|
||||||
log: RefLog::AndReference,
|
|
||||||
},
|
|
||||||
name: full,
|
|
||||||
deref: false,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
}
|
||||||
|
|
||||||
repo.edit_references(edits)?;
|
pub fn try_open(workdir: &Path) -> Option<Self> {
|
||||||
|
Self::open(workdir).ok()
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
// Only history walks benefit from the object cache, they re-decode the
|
||||||
}
|
// same commit objects repeatedly; single-object reads open plain.
|
||||||
|
pub fn open_cached(workdir: &Path) -> Result<Self> {
|
||||||
|
let mut repo = gix::open(workdir)?;
|
||||||
|
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||||
|
Ok(Self { inner: repo })
|
||||||
|
}
|
||||||
|
|
||||||
/// Short name of the branch HEAD points to at `workdir`,
|
pub fn init(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||||
/// `None` when detached or unreadable, like `git branch --show-current`.
|
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||||
pub fn worktree_current_branch(workdir: &Path) -> Option<String> {
|
|
||||||
let repo = gix::open(workdir).ok()?;
|
|
||||||
let head = repo.head().ok()?;
|
|
||||||
let name = head.referent_name()?;
|
|
||||||
Some(String::from_utf8_lossy(name.shorten()).into_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool {
|
std::fs::create_dir_all(path)
|
||||||
let Ok(repo) = gix::open(workdir) else {
|
.with_context(|| format!("failed to create {}", path.display()))?;
|
||||||
return false;
|
|
||||||
};
|
|
||||||
repo.find_reference(name).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fast-forward local branches that trail their remote-tracking counterpart.
|
let repo = gix::init(path)?;
|
||||||
///
|
|
||||||
/// Returns whether any branch moved.
|
|
||||||
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
|
||||||
let repo = gix::open(workdir)?;
|
|
||||||
let current = worktree_current_branch(workdir);
|
|
||||||
let heads = refs_with_prefix(workdir, "refs/heads")?;
|
|
||||||
|
|
||||||
let (signature, mut time_buf) = repository_signature();
|
let (signature, mut time_buf) = Self::repository_signature();
|
||||||
let signature = signature.to_ref(&mut time_buf);
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
let mut moved = false;
|
// The initial branch is `main` regardless of `init.defaultBranch`:
|
||||||
|
// point the unborn HEAD there.
|
||||||
for head in heads {
|
let head = gix::refs::FullName::try_from("HEAD")
|
||||||
let Some(branch) = head.strip_prefix("refs/heads/") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let remote = format!("refs/remotes/origin/{branch}");
|
|
||||||
// No remote-tracking counterpart means the remote lacks this branch.
|
|
||||||
let Ok(mut remote_reference) = repo.find_reference(&remote) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(mut local_reference) = repo.find_reference(&head) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(remote_oid) = remote_reference.peel_to_id() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(local_oid) = local_reference.peel_to_id() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let remote_oid = remote_oid.detach();
|
|
||||||
let local_oid = local_oid.detach();
|
|
||||||
|
|
||||||
if local_oid == remote_oid {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only fast-forward.
|
|
||||||
// Local-only commits or diverged history must never be rewritten by a refresh.
|
|
||||||
let Ok(base) = repo.merge_base(local_oid, remote_oid) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if base != local_oid {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let full = gix::refs::FullName::try_from(head.as_str())
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
|
|
||||||
let edit = |new: gix::refs::Target| {
|
repo.edit_references_as(
|
||||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
[RefEdit {
|
||||||
RefEdit {
|
change: Change::Update {
|
||||||
|
log: LogChange {
|
||||||
|
mode: RefLog::AndReference,
|
||||||
|
force_create_reflog: false,
|
||||||
|
message: "checkout: moving to main".into(),
|
||||||
|
},
|
||||||
|
expected: PreviousValue::Any,
|
||||||
|
new: gix::refs::Target::Symbolic(
|
||||||
|
gix::refs::FullName::try_from("refs/heads/main")
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
name: head,
|
||||||
|
deref: false,
|
||||||
|
}],
|
||||||
|
Some(signature),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let readme = if description.trim().is_empty() {
|
||||||
|
format!("# {name}\n")
|
||||||
|
} else {
|
||||||
|
format!("# {name}\n\n{description}\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?;
|
||||||
|
|
||||||
|
let blob = repo.write_object(gix::objs::Blob {
|
||||||
|
data: readme.into_bytes(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tree = repo.write_object(gix::objs::Tree {
|
||||||
|
entries: vec![gix::objs::tree::Entry {
|
||||||
|
mode: gix::objs::tree::EntryKind::Blob.into(),
|
||||||
|
filename: gix::bstr::BString::from("README.md"),
|
||||||
|
oid: blob.into(),
|
||||||
|
}],
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let commit = repo.commit_as(
|
||||||
|
signature,
|
||||||
|
signature,
|
||||||
|
"HEAD",
|
||||||
|
"Initial commit",
|
||||||
|
tree,
|
||||||
|
Vec::<gix::ObjectId>::new(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Populate the index so the fresh repository is clean, as `git add`
|
||||||
|
// and `git commit` would leave it.
|
||||||
|
let mut index = repo.index_from_tree(&tree)?;
|
||||||
|
index.write(gix::index::write::Options::default())?;
|
||||||
|
|
||||||
|
Ok(commit.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not kept in any cache, unlike `GitCache::ensure_clone`.
|
||||||
|
pub fn clone<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<Self> {
|
||||||
|
if path.exists() {
|
||||||
|
anyhow::bail!("destination {} already exists", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for url in clone_urls {
|
||||||
|
match Self::clone_from(url.as_ref(), path) {
|
||||||
|
Ok(repo) => {
|
||||||
|
// The default-refspec clone misses the `refs/nostr/*` PR refs.
|
||||||
|
repo.fetch().ok();
|
||||||
|
return Ok(repo);
|
||||||
|
}
|
||||||
|
Err(error) => last_error = Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match last_error {
|
||||||
|
Some(error) => Err(error).context("failed to clone from any mirror"),
|
||||||
|
None => anyhow::bail!("no clone URLs provided"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_from(url: &str, path: &Path) -> Result<Self> {
|
||||||
|
let url = Self::transport_url(url);
|
||||||
|
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||||
|
|
||||||
|
let mut prepare = gix::prepare_clone(url, path)?;
|
||||||
|
let (mut checkout, _fetch) =
|
||||||
|
prepare.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
|
||||||
|
let (repo, _checkout) =
|
||||||
|
checkout.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
|
||||||
|
|
||||||
|
Ok(Self { inner: repo })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inner(&self) -> &gix::Repository {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workdir(&self) -> Option<&Path> {
|
||||||
|
self.inner.workdir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// `None` for an unborn HEAD.
|
||||||
|
pub fn head(&self) -> Option<String> {
|
||||||
|
self.inner.head_id().ok().map(|id| id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Ok(None)` when the revisions share no common ancestor — a valid
|
||||||
|
// outcome for a proposal; unresolvable revisions are errors.
|
||||||
|
pub fn merge_base(&self, a: &str, b: &str) -> Result<Option<String>> {
|
||||||
|
let a = self.inner.rev_parse_single(a.as_bytes())?;
|
||||||
|
let b = self.inner.rev_parse_single(b.as_bytes())?;
|
||||||
|
match self.inner.merge_base(a, b) {
|
||||||
|
Ok(id) => Ok(Some(id.to_string())),
|
||||||
|
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest first: the order `git am` creates them.
|
||||||
|
pub fn commits_since(&self, base: Option<&str>) -> Result<Vec<String>> {
|
||||||
|
let head = match self.inner.head_id() {
|
||||||
|
Ok(head) => head,
|
||||||
|
Err(_) if base.is_none() => return Ok(Vec::new()),
|
||||||
|
Err(e) => return Err(e).context("repository has no commits"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(base) = base else {
|
||||||
|
return Ok(vec![head.to_string()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
let base = self.inner.rev_parse_single(base.as_bytes())?;
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
|
||||||
|
for info in self
|
||||||
|
.inner
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||||
|
))
|
||||||
|
.with_hidden([base])
|
||||||
|
.all()?
|
||||||
|
{
|
||||||
|
commits.push(info?.id().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
commits.reverse();
|
||||||
|
|
||||||
|
Ok(commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The NIP-34 announcement's `euc` marker; `None` without commits.
|
||||||
|
pub fn root_commit(&self) -> Result<Option<String>> {
|
||||||
|
let Ok(head) = self.inner.head_id() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
for info in self
|
||||||
|
.inner
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||||
|
))
|
||||||
|
.all()?
|
||||||
|
{
|
||||||
|
let info = info?;
|
||||||
|
if info.parent_ids().next().is_none() {
|
||||||
|
return Ok(Some(info.id().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refs_with_prefix(&self, prefix: &str) -> Result<Vec<String>> {
|
||||||
|
let pattern = prefix.trim_end_matches('/');
|
||||||
|
let mut names = Vec::new();
|
||||||
|
|
||||||
|
for reference in self.inner.references()?.all()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned();
|
||||||
|
|
||||||
|
// Match the pattern itself and everything beneath it.
|
||||||
|
let under_pattern = name
|
||||||
|
.strip_prefix(pattern)
|
||||||
|
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||||
|
|
||||||
|
if under_pattern {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names.sort();
|
||||||
|
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_refs_with_prefix(&self, prefix: &str) -> Result<()> {
|
||||||
|
use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
|
||||||
|
|
||||||
|
let refs = self.refs_with_prefix(prefix)?;
|
||||||
|
if refs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let edits: Vec<RefEdit> = refs
|
||||||
|
.iter()
|
||||||
|
.map(|name| {
|
||||||
|
let full = gix::refs::FullName::try_from(name.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?;
|
||||||
|
Ok(RefEdit {
|
||||||
|
change: Change::Delete {
|
||||||
|
expected: PreviousValue::Any,
|
||||||
|
log: RefLog::AndReference,
|
||||||
|
},
|
||||||
|
name: full,
|
||||||
|
deref: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
self.inner.edit_references(edits)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_branch(&self) -> Option<String> {
|
||||||
|
let head = self.inner.head().ok()?;
|
||||||
|
let name = head.referent_name()?;
|
||||||
|
Some(String::from_utf8_lossy(name.shorten()).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ref_exists(&self, name: &str) -> bool {
|
||||||
|
self.inner.find_reference(name).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn branches(&self) -> Result<Vec<String>> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for reference in self.inner.references()?.local_branches()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tags(&self) -> Result<Vec<String>> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for reference in self.inner.references()?.tags()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ref_state(&self) -> Result<RepoRefState> {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
|
||||||
|
for reference in self.inner.references()?.local_branches()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
refs.push((
|
||||||
|
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||||
|
reference.id().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for reference in self.inner.references()?.tags()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
refs.push((
|
||||||
|
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||||
|
reference.id().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
refs.sort();
|
||||||
|
|
||||||
|
let head = match self.inner.head() {
|
||||||
|
Ok(head) => head
|
||||||
|
.referent_name()
|
||||||
|
.filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
|
||||||
|
.map(|name| String::from_utf8_lossy(name.shorten()).into_owned()),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RepoRefState { refs, head })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast-forward only: local-only commits or diverged history must never
|
||||||
|
// be rewritten by a refresh. Returns whether any branch moved.
|
||||||
|
pub fn fast_forward_branches(&self) -> Result<bool> {
|
||||||
|
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||||
|
|
||||||
|
if self.workdir().is_none() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = self.current_branch();
|
||||||
|
let heads = self.refs_with_prefix("refs/heads")?;
|
||||||
|
|
||||||
|
let (signature, mut time_buf) = Self::repository_signature();
|
||||||
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
|
let mut moved = false;
|
||||||
|
|
||||||
|
for head in heads {
|
||||||
|
let Some(branch) = head.strip_prefix("refs/heads/") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let remote = format!("refs/remotes/origin/{branch}");
|
||||||
|
let Ok(mut remote_reference) = self.inner.find_reference(&remote) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut local_reference) = self.inner.find_reference(&head) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(remote_oid) = remote_reference.peel_to_id() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(local_oid) = local_reference.peel_to_id() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let remote_oid = remote_oid.detach();
|
||||||
|
let local_oid = local_oid.detach();
|
||||||
|
|
||||||
|
if local_oid == remote_oid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fast-forward: the base must be the local tip.
|
||||||
|
let Ok(base) = self.inner.merge_base(local_oid, remote_oid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if base != local_oid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full = gix::refs::FullName::try_from(head.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
|
|
||||||
|
let edit = |new: gix::refs::Target| RefEdit {
|
||||||
change: Change::Update {
|
change: Change::Update {
|
||||||
log: LogChange {
|
log: LogChange {
|
||||||
mode: RefLog::AndReference,
|
mode: RefLog::AndReference,
|
||||||
@@ -351,119 +406,65 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
|||||||
},
|
},
|
||||||
name: full.clone(),
|
name: full.clone(),
|
||||||
deref: false,
|
deref: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if current.as_deref() == Some(branch) {
|
||||||
|
// Only proceed on a clean worktree, like `git merge --ff-only`.
|
||||||
|
if self.is_dirty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree = self.inner.find_object(remote_oid)?.peel_to_tree()?.id;
|
||||||
|
|
||||||
|
self.force_checkout(&tree)?;
|
||||||
|
|
||||||
|
self.inner.edit_references_as(
|
||||||
|
[edit(gix::refs::Target::Object(remote_oid))],
|
||||||
|
Some(signature),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
moved = true;
|
||||||
|
} else {
|
||||||
|
self.inner.edit_references_as(
|
||||||
|
[edit(gix::refs::Target::Object(remote_oid))],
|
||||||
|
Some(signature),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
moved = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like `git -c user.name=… -c user.email=…` per invocation: the repository
|
||||||
|
// works without a global git identity, and `gix` runs no hooks and never signs.
|
||||||
|
pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) {
|
||||||
|
let seconds = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs() as i64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let signature = gix::actor::Signature {
|
||||||
|
name: gix::bstr::BString::from("Signed"),
|
||||||
|
email: gix::bstr::BString::from("signed@localhost"),
|
||||||
|
time: gix::date::Time { seconds, offset: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
if current.as_deref() == Some(branch) {
|
(signature, gix::date::parse::TimeBuf::default())
|
||||||
// Merge so the checked-out worktree follows the branch.
|
|
||||||
// Only proceed on a clean worktree, like `git merge --ff-only`.
|
|
||||||
if worktree_dirty(workdir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id;
|
|
||||||
|
|
||||||
force_checkout(&repo, &tree)?;
|
|
||||||
|
|
||||||
repo.edit_references_as(
|
|
||||||
[edit(gix::refs::Target::Object(remote_oid))],
|
|
||||||
Some(signature),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
moved = true;
|
|
||||||
} else {
|
|
||||||
repo.edit_references_as(
|
|
||||||
[edit(gix::refs::Target::Object(remote_oid))],
|
|
||||||
Some(signature),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
moved = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(moved)
|
// GRASP servers announce `grasp://` clone URLs but the transport is git
|
||||||
}
|
// smart HTTP, so the scheme is rewritten for gix.
|
||||||
|
pub(crate) fn transport_url(url: &str) -> String {
|
||||||
/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically.
|
url.strip_prefix("grasp://")
|
||||||
pub fn repo_branches(repo: &gix::Repository) -> Result<Vec<String>> {
|
.map(|rest| format!("https://{rest}"))
|
||||||
let mut names = Vec::new();
|
.unwrap_or_else(|| url.to_owned())
|
||||||
for reference in repo.references()?.local_branches()? {
|
|
||||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
||||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
|
||||||
}
|
}
|
||||||
names.sort();
|
|
||||||
Ok(names)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically.
|
|
||||||
pub fn repo_tags(repo: &gix::Repository) -> Result<Vec<String>> {
|
|
||||||
let mut names = Vec::new();
|
|
||||||
for reference in repo.references()?.tags()? {
|
|
||||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
||||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
|
||||||
}
|
|
||||||
names.sort();
|
|
||||||
Ok(names)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short names of local branches, `refs/heads/*`, sorted alphabetically.
|
|
||||||
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
|
||||||
repo_branches(&gix::open(workdir)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short name of the branch HEAD points to, or `None` when detached.
|
|
||||||
///
|
|
||||||
/// Detached after checking out a tag or a commit directly.
|
|
||||||
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
|
||||||
let head = repo.head()?;
|
|
||||||
let Some(name) = head.referent_name() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Branch, tag and HEAD refs of a repository.
|
|
||||||
///
|
|
||||||
/// Ready for a NIP-34 kind-30618 repository state announcement.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RepoRefState {
|
pub struct RepoRefState {
|
||||||
/// `(full refname, commit id)` pairs for heads and tags, sorted.
|
|
||||||
pub refs: Vec<(String, String)>,
|
pub refs: Vec<(String, String)>,
|
||||||
/// Short branch name HEAD points to, or `None` when detached.
|
|
||||||
pub head: Option<String>,
|
pub head: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
|
||||||
let mut refs = Vec::new();
|
|
||||||
|
|
||||||
for reference in repo.references()?.local_branches()? {
|
|
||||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
||||||
refs.push((
|
|
||||||
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
|
||||||
reference.id().to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
for reference in repo.references()?.tags()? {
|
|
||||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
||||||
refs.push((
|
|
||||||
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
|
||||||
reference.id().to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
refs.sort();
|
|
||||||
|
|
||||||
let head = match repo.head() {
|
|
||||||
Ok(head) => head
|
|
||||||
.referent_name()
|
|
||||||
.filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
|
|
||||||
.map(|name| String::from_utf8_lossy(name.shorten()).into_owned()),
|
|
||||||
Err(_) => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(RepoRefState { refs, head })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
|
|
||||||
repo_ref_state(&gix::open(workdir)?)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,20 +2,18 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
|
|
||||||
use crate::nip34::{Nip34Binding, detect_nip34};
|
use crate::nip34::Nip34Binding;
|
||||||
|
use crate::repo::Repo;
|
||||||
|
|
||||||
/// Caps nesting so pathological trees can't stall the scan.
|
// Caps nesting so pathological trees can't stall the scan.
|
||||||
const SCAN_MAX_DEPTH: usize = 12;
|
const SCAN_MAX_DEPTH: usize = 12;
|
||||||
|
|
||||||
/// A git repository discovered under a scan root.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalRepo {
|
pub struct LocalRepo {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// `None` for a plain repository.
|
|
||||||
pub nip34: Option<Nip34Binding>,
|
pub nip34: Option<Nip34Binding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk `root` recursively and collect the git repositories below it.
|
|
||||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -49,7 +47,7 @@ pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
|||||||
roots
|
roots
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
let nip34 = detect_nip34(&path);
|
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
|
||||||
LocalRepo { path, nip34 }
|
LocalRepo { path, nip34 }
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
+143
-657
File diff suppressed because it is too large
Load Diff
+287
-307
@@ -4,331 +4,311 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use gix::progress::Discard;
|
use gix::progress::Discard;
|
||||||
|
|
||||||
use crate::history::{FileCommit, head_commit};
|
use crate::history::FileCommit;
|
||||||
use crate::repo::{current_branch, repository_signature};
|
use crate::repo::Repo;
|
||||||
|
|
||||||
/// Whether the worktree of `workdir` has uncommitted changes.
|
impl Repo {
|
||||||
///
|
// Best-effort: any read failure is reported as clean.
|
||||||
/// Best-effort: any read failure is reported as clean.
|
pub fn is_dirty(&self) -> bool {
|
||||||
pub fn worktree_dirty(workdir: &Path) -> bool {
|
// Tracked files, staged or not; untracked files are handled below.
|
||||||
let Ok(repo) = gix::open(workdir) else {
|
match self.inner.is_dirty() {
|
||||||
return false;
|
Ok(true) => return true,
|
||||||
};
|
Ok(false) => {}
|
||||||
|
|
||||||
// Changes to tracked files, staged or not; untracked files are excluded.
|
|
||||||
match repo.is_dirty() {
|
|
||||||
Ok(true) => return true,
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(_) => return false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk,
|
|
||||||
// tracked files only appear there when modified.
|
|
||||||
let Ok(platform) = repo.status(Discard) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
for change in changes.by_ref() {
|
|
||||||
match change {
|
|
||||||
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
false
|
// Untracked files surface as `DirectoryContents` items of the
|
||||||
}
|
// index-vs-worktree walk; tracked files only appear there when modified.
|
||||||
|
let Ok(platform) = self.inner.status(Discard) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
/// Commits in `base..branch` of the checkout at `workdir`.
|
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
||||||
///
|
else {
|
||||||
/// Best-effort: 0 when the range cannot be computed.
|
return false;
|
||||||
pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
|
};
|
||||||
let Ok(repo) = gix::open(workdir) else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch))
|
for change in changes.by_ref() {
|
||||||
else {
|
match change {
|
||||||
return 0;
|
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
||||||
};
|
Ok(_) => {}
|
||||||
|
Err(_) => return false,
|
||||||
let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve `rev` to a commit id, accepting full refs or the bare branch names
|
|
||||||
/// callers pass. `gix`'s revision parser already applies git's ref DWIM.
|
|
||||||
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
|
|
||||||
repo.rev_parse_single(rev.as_bytes()).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relative paths of all entries in the worktree, files and directories.
|
|
||||||
///
|
|
||||||
/// The `.git` directory is skipped.
|
|
||||||
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
|
||||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
|
||||||
|
|
||||||
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
|
||||||
collect_entries(workdir, workdir, &mut entries)?;
|
|
||||||
|
|
||||||
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
|
||||||
b_is_dir
|
|
||||||
.cmp(a_is_dir)
|
|
||||||
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
|
||||||
});
|
|
||||||
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a file from the worktree.
|
|
||||||
///
|
|
||||||
/// Returns `Ok(None)` if the path is missing or not a regular file.
|
|
||||||
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
|
|
||||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
|
||||||
let path = workdir.join(rel);
|
|
||||||
|
|
||||||
match std::fs::read(&path) {
|
|
||||||
Ok(bytes) => Ok(Some(bytes)),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
|
||||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the README file in the repository root.
|
|
||||||
///
|
|
||||||
/// Falls back to any other file whose name starts with `readme`.
|
|
||||||
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
|
||||||
let Some(workdir) = repo.workdir() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
|
||||||
for entry in std::fs::read_dir(workdir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name();
|
|
||||||
let Some(name) = name.to_str() else { continue };
|
|
||||||
if name.to_ascii_lowercase().starts_with("readme") {
|
|
||||||
candidates.push(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates.sort_by_key(|path| {
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
|
||||||
match ext.as_deref() {
|
|
||||||
Some("md") => 0,
|
|
||||||
Some("markdown") => 1,
|
|
||||||
Some("mdown") => 2,
|
|
||||||
Some("mkdn") => 3,
|
|
||||||
Some(_) => 5,
|
|
||||||
None => 4,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(candidates
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
|
||||||
pub struct WorktreeSnapshot {
|
|
||||||
/// Relative paths of all worktree entries, directories first.
|
|
||||||
pub entries: Vec<PathBuf>,
|
|
||||||
/// README path relative to the worktree, if any.
|
|
||||||
pub readme_path: Option<PathBuf>,
|
|
||||||
/// Contents of the README, if any.
|
|
||||||
pub readme: Option<Vec<u8>>,
|
|
||||||
/// Branch HEAD points to, `None` when detached, for example on a tag.
|
|
||||||
pub current_branch: Option<String>,
|
|
||||||
/// Commit HEAD points to, if any, see [`head_commit`].
|
|
||||||
pub head_commit: Option<FileCommit>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
|
||||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
|
||||||
let repo = gix::open(workdir)?;
|
|
||||||
let readme_path = find_readme(&repo)?;
|
|
||||||
let readme = match &readme_path {
|
|
||||||
Some(path) => worktree_read(&repo, path)?,
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
Ok(WorktreeSnapshot {
|
|
||||||
entries: worktree_entries(&repo)?,
|
|
||||||
readme_path,
|
|
||||||
readme,
|
|
||||||
current_branch: current_branch(&repo)?,
|
|
||||||
head_commit: head_commit(&repo)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
|
|
||||||
let workdir = repo
|
|
||||||
.workdir()
|
|
||||||
.context("repository has no worktree")?
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
let mut index = repo.index_from_tree(tree)?;
|
|
||||||
|
|
||||||
// Files the previous index tracked but `tree` no longer contains are removed,
|
|
||||||
// like git deleting files that vanish between branches.
|
|
||||||
if let Ok(previous) = repo.index_or_empty() {
|
|
||||||
let keep: HashSet<PathBuf> = index
|
|
||||||
.entries()
|
|
||||||
.iter()
|
|
||||||
.map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()))
|
|
||||||
.collect();
|
|
||||||
for entry in previous.entries() {
|
|
||||||
let rel = entry.path(&previous);
|
|
||||||
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
|
|
||||||
|
|
||||||
if keep.contains(&rel) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let path = workdir.join(&rel);
|
false
|
||||||
|
}
|
||||||
|
|
||||||
match std::fs::remove_file(&path) {
|
// Best-effort: 0 when the range cannot be computed.
|
||||||
Ok(()) => {}
|
pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 {
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch))
|
||||||
Err(error) => {
|
else {
|
||||||
return Err(error)
|
return 0;
|
||||||
.with_context(|| format!("failed to remove {}", path.display()));
|
};
|
||||||
|
|
||||||
|
let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts full refs or the bare branch names callers pass; `gix`'s
|
||||||
|
// revision parser already applies git's ref DWIM.
|
||||||
|
fn resolve_commit<'a>(&'a self, rev: &str) -> Option<gix::Id<'a>> {
|
||||||
|
self.inner.rev_parse_single(rev.as_bytes()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `.git` directory is skipped.
|
||||||
|
pub fn entries(&self) -> Result<Vec<PathBuf>> {
|
||||||
|
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
||||||
|
|
||||||
|
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
||||||
|
Self::collect_entries(workdir, workdir, &mut entries)?;
|
||||||
|
|
||||||
|
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
||||||
|
b_is_dir
|
||||||
|
.cmp(a_is_dir)
|
||||||
|
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
||||||
|
});
|
||||||
|
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Ok(None)` when the path is missing or not a regular file.
|
||||||
|
pub fn read(&self, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
|
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
||||||
|
let path = workdir.join(rel);
|
||||||
|
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => Ok(Some(bytes)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
||||||
|
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls back to any other file whose name starts with `readme`.
|
||||||
|
pub fn find_readme(&self) -> Result<Option<PathBuf>> {
|
||||||
|
let Some(workdir) = self.inner.workdir() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(workdir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if name.to_ascii_lowercase().starts_with("readme") {
|
||||||
|
candidates.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort_by_key(|path| {
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||||
|
match ext.as_deref() {
|
||||||
|
Some("md") => 0,
|
||||||
|
Some("markdown") => 1,
|
||||||
|
Some("mdown") => 2,
|
||||||
|
Some("mkdn") => 3,
|
||||||
|
Some(_) => 5,
|
||||||
|
None => 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(candidates
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Result<WorktreeSnapshot> {
|
||||||
|
let readme_path = self.find_readme()?;
|
||||||
|
let readme = match &readme_path {
|
||||||
|
Some(path) => self.read(path)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(WorktreeSnapshot {
|
||||||
|
entries: self.entries()?,
|
||||||
|
readme_path,
|
||||||
|
readme,
|
||||||
|
current_branch: self.current_branch(),
|
||||||
|
head_commit: self.head_commit().unwrap_or(None),
|
||||||
|
branches: self.branches().unwrap_or_default(),
|
||||||
|
tags: self.tags().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD stays attached to the branch.
|
||||||
|
pub fn checkout_branch(&self, name: &str) -> Result<()> {
|
||||||
|
let full = format!("refs/heads/{name}");
|
||||||
|
|
||||||
|
let branch = gix::refs::FullName::try_from(full.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
|
|
||||||
|
let mut reference = self.inner.find_reference(&full)?;
|
||||||
|
let tree = reference.peel_to_tree()?.id;
|
||||||
|
|
||||||
|
let (signature, mut time_buf) = Self::repository_signature();
|
||||||
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
|
self.move_head(
|
||||||
|
signature,
|
||||||
|
gix::refs::Target::Symbolic(branch),
|
||||||
|
&format!("checkout: moving to {name}"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.force_checkout(&tree)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD becomes detached at the tagged commit.
|
||||||
|
pub fn checkout_tag(&self, name: &str) -> Result<()> {
|
||||||
|
let full = format!("refs/tags/{name}");
|
||||||
|
|
||||||
|
let mut reference = self.inner.find_reference(&full)?;
|
||||||
|
|
||||||
|
let commit = reference.peel_to_id()?;
|
||||||
|
let tree = reference.peel_to_tree()?.id;
|
||||||
|
|
||||||
|
let (signature, mut time_buf) = Self::repository_signature();
|
||||||
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
|
self.move_head(
|
||||||
|
signature,
|
||||||
|
gix::refs::Target::Object(commit.detach()),
|
||||||
|
&format!("checkout: moving to {name}"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.force_checkout(&tree)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> {
|
||||||
|
let workdir = self
|
||||||
|
.inner
|
||||||
|
.workdir()
|
||||||
|
.context("repository has no worktree")?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
let mut index = self.inner.index_from_tree(tree)?;
|
||||||
|
|
||||||
|
// Files the previous index tracked but `tree` no longer contains are
|
||||||
|
// removed, like git deleting files that vanish between branches.
|
||||||
|
if let Ok(previous) = self.inner.index_or_empty() {
|
||||||
|
let keep: HashSet<PathBuf> = index
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for entry in previous.entries() {
|
||||||
|
let rel = entry.path(&previous);
|
||||||
|
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
|
||||||
|
|
||||||
|
if keep.contains(&rel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = workdir.join(&rel);
|
||||||
|
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error)
|
||||||
|
.with_context(|| format!("failed to remove {}", path.display()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut options = self
|
||||||
|
.inner
|
||||||
|
.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
||||||
|
options.overwrite_existing = true;
|
||||||
|
|
||||||
|
let objects = self.inner.objects.clone().into_arc()?;
|
||||||
|
let files = gix::progress::Discard;
|
||||||
|
let bytes = gix::progress::Discard;
|
||||||
|
|
||||||
|
gix_worktree_state::checkout(
|
||||||
|
&mut index,
|
||||||
|
workdir,
|
||||||
|
objects,
|
||||||
|
&files,
|
||||||
|
&bytes,
|
||||||
|
&gix::interrupt::IS_INTERRUPTED,
|
||||||
|
options,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
index.write(gix::index::write::Options::default())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut options =
|
fn move_head(
|
||||||
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
&self,
|
||||||
options.overwrite_existing = true;
|
signature: gix::actor::SignatureRef<'_>,
|
||||||
|
target: gix::refs::Target,
|
||||||
|
message: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||||
|
|
||||||
let objects = repo.objects.clone().into_arc()?;
|
let head = gix::refs::FullName::try_from("HEAD")
|
||||||
let files = gix::progress::Discard;
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
let bytes = gix::progress::Discard;
|
|
||||||
|
|
||||||
gix_worktree_state::checkout(
|
self.inner.edit_references_as(
|
||||||
&mut index,
|
[RefEdit {
|
||||||
workdir,
|
change: Change::Update {
|
||||||
objects,
|
log: LogChange {
|
||||||
&files,
|
mode: RefLog::AndReference,
|
||||||
&bytes,
|
force_create_reflog: false,
|
||||||
&gix::interrupt::IS_INTERRUPTED,
|
message: message.into(),
|
||||||
options,
|
},
|
||||||
)?;
|
expected: PreviousValue::Any,
|
||||||
|
new: target,
|
||||||
index.write(gix::index::write::Options::default())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Point `HEAD` at `target` and record the switch in the reflog.
|
|
||||||
fn move_head(
|
|
||||||
repo: &gix::Repository,
|
|
||||||
signature: gix::actor::SignatureRef<'_>,
|
|
||||||
target: gix::refs::Target,
|
|
||||||
message: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
|
||||||
|
|
||||||
let head = gix::refs::FullName::try_from("HEAD")
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
|
||||||
|
|
||||||
repo.edit_references_as(
|
|
||||||
[RefEdit {
|
|
||||||
change: Change::Update {
|
|
||||||
log: LogChange {
|
|
||||||
mode: RefLog::AndReference,
|
|
||||||
force_create_reflog: false,
|
|
||||||
message: message.into(),
|
|
||||||
},
|
},
|
||||||
expected: PreviousValue::Any,
|
name: head,
|
||||||
new: target,
|
deref: false,
|
||||||
},
|
}],
|
||||||
name: head,
|
Some(signature),
|
||||||
deref: false,
|
)?;
|
||||||
}],
|
|
||||||
Some(signature),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check out the local branch `name`, HEAD stays attached to it.
|
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||||
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
for entry in std::fs::read_dir(dir)? {
|
||||||
let repo = gix::open(workdir)?;
|
let entry = entry?;
|
||||||
let full = format!("refs/heads/{name}");
|
if entry.file_name() == ".git" {
|
||||||
|
continue;
|
||||||
let branch = gix::refs::FullName::try_from(full.as_str())
|
}
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
|
||||||
|
let is_dir = entry.file_type()?.is_dir();
|
||||||
let mut reference = repo.find_reference(&full)?;
|
let path = entry.path();
|
||||||
let tree = reference.peel_to_tree()?.id;
|
let rel = path.strip_prefix(root)?.to_path_buf();
|
||||||
|
out.push((rel, is_dir));
|
||||||
let (signature, mut time_buf) = repository_signature();
|
|
||||||
let signature = signature.to_ref(&mut time_buf);
|
if is_dir {
|
||||||
|
Self::collect_entries(root, &path, out)?;
|
||||||
move_head(
|
}
|
||||||
&repo,
|
|
||||||
signature,
|
|
||||||
gix::refs::Target::Symbolic(branch),
|
|
||||||
&format!("checkout: moving to {name}"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
force_checkout(&repo, &tree)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check out the tag `name`, HEAD becomes detached at the tagged commit.
|
|
||||||
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
|
||||||
let repo = gix::open(workdir)?;
|
|
||||||
let full = format!("refs/tags/{name}");
|
|
||||||
|
|
||||||
let mut reference = repo.find_reference(&full)?;
|
|
||||||
|
|
||||||
let commit = reference.peel_to_id()?;
|
|
||||||
let tree = reference.peel_to_tree()?.id;
|
|
||||||
|
|
||||||
let (signature, mut time_buf) = repository_signature();
|
|
||||||
let signature = signature.to_ref(&mut time_buf);
|
|
||||||
|
|
||||||
move_head(
|
|
||||||
&repo,
|
|
||||||
signature,
|
|
||||||
gix::refs::Target::Object(commit.detach()),
|
|
||||||
&format!("checkout: moving to {name}"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
force_checkout(&repo, &tree)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
|
||||||
for entry in std::fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
if entry.file_name() == ".git" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_dir = entry.file_type()?.is_dir();
|
|
||||||
let path = entry.path();
|
|
||||||
let rel = path.strip_prefix(root)?.to_path_buf();
|
|
||||||
out.push((rel, is_dir));
|
|
||||||
|
|
||||||
if is_dir {
|
|
||||||
collect_entries(root, &path, out)?;
|
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
pub struct WorktreeSnapshot {
|
||||||
|
pub entries: Vec<PathBuf>,
|
||||||
|
pub readme_path: Option<PathBuf>,
|
||||||
|
pub readme: Option<Vec<u8>>,
|
||||||
|
// `None` when detached, for example on a tag.
|
||||||
|
pub current_branch: Option<String>,
|
||||||
|
pub head_commit: Option<FileCommit>,
|
||||||
|
pub branches: Vec<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ publish.workspace = true
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
nostr-gossip-memory.workspace = true
|
nostr-gossip-memory.workspace = true
|
||||||
|
nostr-lmdb.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
|
||||||
nostr-memory.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
nostr-lmdb.workspace = true
|
|
||||||
|
|||||||
@@ -1,51 +1,47 @@
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use nostr_lmdb::prelude::*;
|
use nostr_lmdb::prelude::*;
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use nostr_memory::prelude::*;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::signer::UniversalSigner;
|
use crate::signer::UniversalSigner;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
pub struct NostrBackend {
|
||||||
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
pub client: Client,
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
pub signer: UniversalSigner,
|
||||||
let database = NostrLmdb::open(db_path)
|
|
||||||
.await
|
|
||||||
.context("failed to open nostr database")?;
|
|
||||||
Ok(with_database(signer, database))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory database on wasm, LMDB is unavailable there.
|
impl NostrBackend {
|
||||||
#[cfg(target_arch = "wasm32")]
|
pub async fn open(db_path: impl AsRef<Path>) -> Result<Self> {
|
||||||
pub fn new_backend() -> Result<(Client, UniversalSigner)> {
|
let db_path = db_path.as_ref();
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
Ok(with_database(signer, MemoryDatabase::unbounded()))
|
let database = NostrLmdb::open(db_path)
|
||||||
}
|
.await
|
||||||
|
.context("failed to open nostr database")?;
|
||||||
|
Ok(Self::with_database(signer, database))
|
||||||
|
}
|
||||||
|
|
||||||
fn with_database<D>(signer: UniversalSigner, database: D) -> (Client, UniversalSigner)
|
fn with_database<D>(signer: UniversalSigner, database: D) -> Self
|
||||||
where
|
where
|
||||||
D: IntoNostrDatabase,
|
D: IntoNostrDatabase,
|
||||||
{
|
{
|
||||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||||
|
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
.database(database)
|
.database(database)
|
||||||
.authenticator(authenticator)
|
.authenticator(authenticator)
|
||||||
.gossip(NostrGossipMemory::unbounded())
|
.gossip(NostrGossipMemory::unbounded())
|
||||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||||
.connect_timeout(Duration::from_secs(10))
|
.connect_timeout(Duration::from_secs(10))
|
||||||
.verify_subscriptions(true)
|
.verify_subscriptions(true)
|
||||||
.ban_relay_on_mismatch(true)
|
.ban_relay_on_mismatch(true)
|
||||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
timeout: Duration::from_secs(600),
|
timeout: Duration::from_secs(600),
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
(client, signer)
|
Self { client, signer }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ mod backend;
|
|||||||
mod signer;
|
mod signer;
|
||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
pub use backend::new_backend;
|
pub use backend::NostrBackend;
|
||||||
pub use signer::{SignedAuthUrlHandler, UniversalSigner};
|
pub use signer::{SignedAuthUrlHandler, UniversalSigner};
|
||||||
pub use update::Update;
|
pub use update::Update;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use nostr_connect::client::AuthUrlHandler;
|
|||||||
use nostr_sdk::error::Error as SignerError;
|
use nostr_sdk::error::Error as SignerError;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// A type-erased signer whose inner signer can be swapped in-place.
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
@@ -27,7 +26,6 @@ impl UniversalSigner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Swap the inner signer in-place. All clones see the new signer.
|
|
||||||
pub fn swap_inner<T>(&self, new_signer: T)
|
pub fn swap_inner<T>(&self, new_signer: T)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
@@ -160,7 +158,6 @@ impl AsyncNip44 for UniversalSigner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the NIP-46 auth URL in the default browser.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SignedAuthUrlHandler;
|
pub struct SignedAuthUrlHandler;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// A lightweight change notification for the UI.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Update {
|
pub struct Update {
|
||||||
pub kind: Kind,
|
pub kind: Kind,
|
||||||
/// First `a` tag value of the event, if any, for example the repository coordinate.
|
|
||||||
pub coordinate: Option<Coordinate>,
|
pub coordinate: Option<Coordinate>,
|
||||||
pub author: PublicKey,
|
pub author: PublicKey,
|
||||||
}
|
}
|
||||||
|
|||||||
+264
-1147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use nostr_sdk::client::SyncSummary;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_core::Filters;
|
||||||
|
|
||||||
|
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
||||||
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||||
|
|
||||||
|
async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
|
||||||
|
for url in BOOTSTRAP_RELAYS {
|
||||||
|
client.add_relay(url).and_connect().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for url in INDEXER_RELAYS {
|
||||||
|
client
|
||||||
|
.add_relay(url)
|
||||||
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
|
.and_connect()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn subscribe_bootstrap_only(
|
||||||
|
client: &Client,
|
||||||
|
filters: Vec<Filter>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
|
.timeout(Some(Duration::from_secs(10)));
|
||||||
|
|
||||||
|
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||||
|
.iter()
|
||||||
|
.map(|relay| (*relay, filters.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
client.subscribe(target).close_on(opts).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn sync_bootstrap_only(
|
||||||
|
client: &Client,
|
||||||
|
filter: Filter,
|
||||||
|
opts: SyncOptions,
|
||||||
|
) -> Result<SyncSummary, Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
|
let output = client
|
||||||
|
.sync(filter)
|
||||||
|
.with(BOOTSTRAP_RELAYS)
|
||||||
|
.opts(opts)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(output.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|tag| tag.kind() == "g")
|
||||||
|
.filter_map(|tag| tag.content())
|
||||||
|
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|event| event.created_at)
|
||||||
|
.map(|event| grasp_list_servers(&event))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_grasp_list_servers(
|
||||||
|
client: &Client,
|
||||||
|
user: PublicKey,
|
||||||
|
) -> Result<Vec<RelayUrl>, Error> {
|
||||||
|
let events: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(Filters::grasp_list(user))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
Ok(latest_grasp_list_servers(events))
|
||||||
|
}
|
||||||
@@ -7,20 +7,19 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use settings::{CheckoutRecord, SettingsStore};
|
use settings::{CheckoutRecord, SettingsStore};
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
use signed_git::Repo;
|
||||||
|
use utils::same_repo_url;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::git_store::repo_mirror_root;
|
use crate::git_store::Mirrors;
|
||||||
use crate::local_repos::LocalReposStore;
|
use crate::local_repos::LocalReposStore;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
use crate::repos::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// How often the statuses are recomputed against the local refs.
|
|
||||||
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
||||||
/// How often a full pass refreshes the remotes while any repository panel is open.
|
|
||||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||||
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
|
||||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
const MAX_STATUS_CHECKOUTS: usize = 8;
|
const MAX_STATUS_CHECKOUTS: usize = 8;
|
||||||
@@ -29,63 +28,37 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
|||||||
|
|
||||||
impl Global for GlobalCheckoutsStore {}
|
impl Global for GlobalCheckoutsStore {}
|
||||||
|
|
||||||
/// One associated local checkout of a repository.
|
|
||||||
///
|
|
||||||
/// Carries the git facts needed to suggest a pull request.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CheckoutStatus {
|
pub struct CheckoutStatus {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// The branch checked out. A detached checkout is idle and yields no status.
|
// A detached checkout is idle and yields no status.
|
||||||
pub branch: String,
|
pub branch: String,
|
||||||
/// Commit the branch points at, for tip-based PR dedupe.
|
// For tip-based PR dedupe.
|
||||||
pub head: String,
|
pub head: String,
|
||||||
/// What the branch is compared against.
|
// `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||||
///
|
|
||||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
|
||||||
pub base: String,
|
pub base: String,
|
||||||
/// Commits in `base..branch`.
|
// Zero-ahead checkouts are dropped, so always above zero.
|
||||||
///
|
|
||||||
/// Zero-ahead checkouts are dropped, so this is always above zero.
|
|
||||||
pub ahead: u32,
|
pub ahead: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A remembered record, with the address already parsed.
|
|
||||||
struct Remembered {
|
struct Remembered {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
addr: RepoAddr,
|
addr: RepoAddr,
|
||||||
last_used: u64,
|
last_used: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global store of local-checkout associations and per-checkout statuses.
|
|
||||||
pub struct CheckoutsStore {
|
pub struct CheckoutsStore {
|
||||||
/// Checkout paths per announced repository.
|
|
||||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
/// Ready-to-contribute statuses of the requested repositories.
|
|
||||||
///
|
|
||||||
/// Those are the repository detail panels currently open.
|
|
||||||
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||||
/// Repositories whose statuses are recomputed on every input change.
|
|
||||||
///
|
|
||||||
/// Those are the repository detail panels currently open.
|
|
||||||
status_requested: HashSet<RepoAddr>,
|
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 and their detail panels.
|
|
||||||
push_requested: HashSet<RepoAddr>,
|
push_requested: HashSet<RepoAddr>,
|
||||||
/// The ready-to-push statuses of the requested own repositories.
|
|
||||||
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||||
/// Last announced head branch per requested repository.
|
|
||||||
///
|
|
||||||
/// A recompute defaults the base the same way.
|
|
||||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
/// True while the timer between a scheduled refresh and its run is pending.
|
|
||||||
debounce_pending: bool,
|
debounce_pending: bool,
|
||||||
local_pending: bool,
|
local_pending: bool,
|
||||||
/// When the last full pass (with a remote refresh) completed.
|
// The local pass runs a full pass again once this is older than the
|
||||||
///
|
// reconciliation cadence, so remote moves still land.
|
||||||
/// The local pass runs a full pass again once this is older than the
|
|
||||||
/// reconciliation cadence, so remote moves still land.
|
|
||||||
last_full_sync: Option<Instant>,
|
last_full_sync: Option<Instant>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
@@ -187,17 +160,10 @@ impl CheckoutsStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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> {
|
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
||||||
self.by_repo.get(addr).cloned().unwrap_or_default()
|
self.by_repo.get(addr).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(
|
pub fn request_statuses(
|
||||||
&mut self,
|
&mut self,
|
||||||
addr: &RepoAddr,
|
addr: &RepoAddr,
|
||||||
@@ -211,26 +177,18 @@ impl CheckoutsStore {
|
|||||||
self.refresh(cx);
|
self.refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ready-to-contribute statuses of `addr`.
|
|
||||||
///
|
|
||||||
/// Empty while none are known or nothing is ahead.
|
|
||||||
pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask for the `ready to push` statuses of `addr` to stay current.
|
|
||||||
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
|
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
|
||||||
self.push_requested.insert(addr.clone());
|
self.push_requested.insert(addr.clone());
|
||||||
self.refresh(cx);
|
self.refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The checkout at `path` was just pushed to the remote.
|
// Drop the stale ready-to-push status and notify observers right away, so
|
||||||
///
|
// the sidebar badge updates immediately instead of waiting for the next
|
||||||
/// Its ready-to-push status is obsolete. Drop it from the cached statuses
|
// background pass. The debounced refresh reconciles the remaining checkouts.
|
||||||
/// and notify observers right away, so the sidebar badge and the push
|
|
||||||
/// banner update immediately instead of waiting for the next background
|
|
||||||
/// pass, which re-scans and re-fetches the remote. The debounced refresh
|
|
||||||
/// reconciles the remaining checkouts of the repository afterwards.
|
|
||||||
pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context<Self>) {
|
pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context<Self>) {
|
||||||
let mut removed = false;
|
let mut removed = false;
|
||||||
|
|
||||||
@@ -253,9 +211,6 @@ impl CheckoutsStore {
|
|||||||
self.request_push_statuses(addr, cx);
|
self.request_push_statuses(addr, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ready-to-push statuses of `addr`.
|
|
||||||
///
|
|
||||||
/// Empty while none are known or nothing is unpushed.
|
|
||||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
@@ -267,7 +222,6 @@ impl CheckoutsStore {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-resolve the associations and the requested statuses.
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
|
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
|
||||||
return;
|
return;
|
||||||
@@ -282,7 +236,6 @@ impl CheckoutsStore {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One full resolve and apply cycle, the debounced entry point.
|
|
||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
self.debounce_pending = false;
|
self.debounce_pending = false;
|
||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
@@ -306,7 +259,7 @@ impl CheckoutsStore {
|
|||||||
|
|
||||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||||
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
||||||
let cache_root = repo_mirror_root().canonicalize().ok();
|
let cache_root = Mirrors::root().canonicalize().ok();
|
||||||
|
|
||||||
let requested: Vec<(RepoAddr, Option<String>)> = self
|
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||||
.status_requested
|
.status_requested
|
||||||
@@ -323,35 +276,38 @@ impl CheckoutsStore {
|
|||||||
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
|
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
// 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();
|
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||||
for scanned in scanned.iter() {
|
for scanned in scanned.iter() {
|
||||||
let path = &scanned.path;
|
let path = &scanned.path;
|
||||||
|
|
||||||
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
|
// The browser's mirror clones are not user checkouts.
|
||||||
if cache_root
|
if cache_root
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|root| path.starts_with(root))
|
.is_some_and(|root| path.starts_with(root))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let origin = signed_git::origin_url(path).ok().flatten();
|
let origin = Repo::open(path)
|
||||||
let root = signed_git::root_commit(path).ok().flatten();
|
.and_then(|repo| repo.origin_url())
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let root = Repo::open(path)
|
||||||
|
.and_then(|repo| repo.root_commit())
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
facts.push((path.clone(), origin, root));
|
facts.push((path.clone(), origin, root));
|
||||||
}
|
}
|
||||||
|
|
||||||
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
let associations =
|
||||||
|
CheckoutsStore::resolve_associations(&remembered, &facts, announcements.iter());
|
||||||
|
|
||||||
// Missing directories are stale records, drop them.
|
|
||||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let (statuses, push_statuses) =
|
let (statuses, push_statuses) =
|
||||||
compute_statuses(&associations, &requested, &push_requested, true);
|
CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, true);
|
||||||
|
|
||||||
Ok::<_, Error>((associations, statuses, push_statuses))
|
Ok::<_, Error>((associations, statuses, push_statuses))
|
||||||
});
|
});
|
||||||
@@ -360,7 +316,6 @@ impl CheckoutsStore {
|
|||||||
let (associations, statuses, push_statuses) = match work.await {
|
let (associations, statuses, push_statuses) = match work.await {
|
||||||
Ok(results) => results,
|
Ok(results) => results,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Git reads are best-effort, keep the last results.
|
|
||||||
return this.update(cx, |this, cx| {
|
return this.update(cx, |this, cx| {
|
||||||
this.refresh.abort();
|
this.refresh.abort();
|
||||||
if poll {
|
if poll {
|
||||||
@@ -379,8 +334,7 @@ impl CheckoutsStore {
|
|||||||
this.statuses = statuses;
|
this.statuses = statuses;
|
||||||
this.push_statuses = push_statuses;
|
this.push_statuses = push_statuses;
|
||||||
|
|
||||||
// Notify only when something actually changed, so observers
|
// Notify only when something actually changed.
|
||||||
// skip the no-op heartbeats.
|
|
||||||
if associations_changed || statuses_changed || push_statuses_changed {
|
if associations_changed || statuses_changed || push_statuses_changed {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -393,9 +347,6 @@ impl CheckoutsStore {
|
|||||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart the fast local pass so the freshly resolved
|
|
||||||
// associations drive it. The pass itself decides when the next
|
|
||||||
// full pass runs.
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
if poll {
|
if poll {
|
||||||
this.schedule_local_pass(cx);
|
this.schedule_local_pass(cx);
|
||||||
@@ -407,7 +358,6 @@ impl CheckoutsStore {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule the fast local status pass, unless one is already pending.
|
|
||||||
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.local_pending {
|
if self.local_pending {
|
||||||
return;
|
return;
|
||||||
@@ -424,20 +374,17 @@ impl CheckoutsStore {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fast local status pass.
|
|
||||||
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
||||||
// Nothing watched: the chain idles out until a new request restarts it.
|
// Nothing watched: the pass idles until a new request restarts it.
|
||||||
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A full pass or a fresh request covers this tick, skip it.
|
|
||||||
if self.refresh.running() || self.debounce_pending {
|
if self.refresh.running() || self.debounce_pending {
|
||||||
self.schedule_local_pass(cx);
|
self.schedule_local_pass(cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open panels get the faster remote cadence.
|
|
||||||
let cadence = if self.status_requested.is_empty() {
|
let cadence = if self.status_requested.is_empty() {
|
||||||
PUSH_POLL
|
PUSH_POLL
|
||||||
} else {
|
} else {
|
||||||
@@ -458,7 +405,6 @@ impl CheckoutsStore {
|
|||||||
self.schedule_local_pass(cx);
|
self.schedule_local_pass(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute the requested statuses against the tracking refs only.
|
|
||||||
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
let associations = self.by_repo.clone();
|
let associations = self.by_repo.clone();
|
||||||
|
|
||||||
@@ -477,19 +423,18 @@ impl CheckoutsStore {
|
|||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let (statuses, push_statuses) =
|
let (statuses, push_statuses) =
|
||||||
compute_statuses(&associations, &requested, &push_requested, false);
|
CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, false);
|
||||||
Ok::<_, Error>((statuses, push_statuses))
|
Ok::<_, Error>((statuses, push_statuses))
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let Ok((statuses, push_statuses)) = work.await else {
|
let Ok((statuses, push_statuses)) = work.await else {
|
||||||
// Git reads are best-effort, keep the last results.
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
// A full pass or a fresh request will apply fresher data
|
// The tracking refs move only when a full pass fetches; a full
|
||||||
// (the tracking refs move only when a full pass fetches).
|
// pass or a fresh request will apply fresher data.
|
||||||
if this.refresh.running() || this.debounce_pending {
|
if this.refresh.running() || this.debounce_pending {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -511,217 +456,201 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
impl CheckoutsStore {
|
||||||
let parsed = Url::parse(url).ok()?;
|
fn resolve_associations<'a>(
|
||||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
remembered: &[Remembered],
|
||||||
let mut path = parsed.path().trim_matches('/').to_owned();
|
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||||
if let Some(stripped) = path.strip_suffix(".git") {
|
announcements: impl IntoIterator<Item = &'a Announcement>,
|
||||||
path = stripped.to_owned();
|
) -> HashMap<RepoAddr, Vec<PathBuf>> {
|
||||||
}
|
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
|
||||||
Some((host, parsed.port(), path))
|
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
|
||||||
}
|
|
||||||
|
|
||||||
fn same_repo_url(a: &str, b: &str) -> bool {
|
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
|
||||||
match (url_identity(a), url_identity(b)) {
|
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
|
||||||
(Some(a), Some(b)) => a == b,
|
for record in sorted {
|
||||||
_ => a == b,
|
let paths = out.entry(record.addr.clone()).or_default();
|
||||||
}
|
if !paths.contains(&record.path) {
|
||||||
}
|
paths.push(record.path.clone());
|
||||||
|
}
|
||||||
fn resolve_associations<'a>(
|
|
||||||
remembered: &[Remembered],
|
|
||||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
|
||||||
announcements: impl IntoIterator<Item = &'a Announcement>,
|
|
||||||
) -> HashMap<RepoAddr, Vec<PathBuf>> {
|
|
||||||
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
|
|
||||||
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
|
|
||||||
|
|
||||||
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
|
|
||||||
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
|
|
||||||
for record in sorted {
|
|
||||||
let paths = out.entry(record.addr.clone()).or_default();
|
|
||||||
if !paths.contains(&record.path) {
|
|
||||||
paths.push(record.path.clone());
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (path, origin, root) in scanned {
|
for (path, origin, root) in scanned {
|
||||||
for announcement in &announcements {
|
for announcement in &announcements {
|
||||||
let url_match = origin.as_deref().is_some_and(|origin| {
|
let url_match = origin.as_deref().is_some_and(|origin| {
|
||||||
announcement
|
announcement
|
||||||
.clone
|
.clone
|
||||||
.iter()
|
.iter()
|
||||||
.any(|url| same_repo_url(origin, url.as_str()))
|
.any(|url| same_repo_url(origin, url.as_str()))
|
||||||
});
|
});
|
||||||
|
|
||||||
let euc_match = root
|
let euc_match = root
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||||
|
|
||||||
if url_match || euc_match {
|
if url_match || euc_match {
|
||||||
let paths = out.entry(announcement.addr()).or_default();
|
let paths = out.entry(announcement.addr()).or_default();
|
||||||
if !paths.contains(path) {
|
if !paths.contains(path) {
|
||||||
paths.push(path.clone());
|
paths.push(path.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||||
}
|
let repo = Repo::try_open(path)?;
|
||||||
|
let branches = repo.branches().ok()?;
|
||||||
|
|
||||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
if branches.is_empty() || repo.is_dirty() {
|
||||||
let branches = signed_git::worktree_branches(path).ok()?;
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
if branches.is_empty() || signed_git::worktree_dirty(path) {
|
let branch = repo.current_branch()?;
|
||||||
return None;
|
let head = repo.head()?;
|
||||||
|
let base = announced_head
|
||||||
|
.filter(|name| branches.iter().any(|b| b == name))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
|
||||||
|
.or_else(|| branches.first().cloned())?;
|
||||||
|
|
||||||
|
if base == branch {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ahead = repo.commits_ahead(&base, &branch);
|
||||||
|
(ahead > 0).then_some(CheckoutStatus {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
branch,
|
||||||
|
head,
|
||||||
|
base,
|
||||||
|
ahead,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let branch = signed_git::worktree_current_branch(path)?;
|
// Never fetches the checked-out refs; reads the tracking refs as-is.
|
||||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||||
let base = announced_head
|
let repo = Repo::try_open(path)?;
|
||||||
.filter(|name| branches.iter().any(|b| b == name))
|
if repo.is_dirty() {
|
||||||
.map(str::to_owned)
|
return None;
|
||||||
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
|
}
|
||||||
.or_else(|| branches.first().cloned())?;
|
|
||||||
|
|
||||||
if base == branch {
|
let branch = repo.current_branch()?;
|
||||||
return None;
|
let head = repo.head()?;
|
||||||
}
|
let origin = repo.origin_url().ok().flatten()?;
|
||||||
|
|
||||||
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
|
if fetch {
|
||||||
(ahead > 0).then_some(CheckoutStatus {
|
repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*")
|
||||||
path: path.to_path_buf(),
|
.ok();
|
||||||
branch,
|
}
|
||||||
head,
|
|
||||||
base,
|
|
||||||
ahead,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `ready to push` status of one checkout of the user's own repository.
|
let remote = format!("refs/remotes/origin/{branch}");
|
||||||
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
|
||||||
if signed_git::worktree_dirty(path) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let branch = signed_git::worktree_current_branch(path)?;
|
// A branch never fetched or pushed yet compares against the remote
|
||||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
// HEAD, the fork point in practice.
|
||||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
let base = if repo.ref_exists(&remote) {
|
||||||
|
remote
|
||||||
if fetch {
|
} else if repo.ref_exists("refs/remotes/origin/HEAD") {
|
||||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
"refs/remotes/origin/HEAD".to_owned()
|
||||||
}
|
} else {
|
||||||
|
return None;
|
||||||
let remote = format!("refs/remotes/origin/{branch}");
|
|
||||||
|
|
||||||
// A branch never fetched or pushed yet compares against the remote HEAD.
|
|
||||||
// The remote HEAD is the fork point in practice.
|
|
||||||
let base = if signed_git::worktree_ref_exists(path, &remote) {
|
|
||||||
remote
|
|
||||||
} else if signed_git::worktree_ref_exists(path, "refs/remotes/origin/HEAD") {
|
|
||||||
"refs/remotes/origin/HEAD".to_owned()
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
|
|
||||||
|
|
||||||
(ahead > 0).then_some(CheckoutStatus {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
branch,
|
|
||||||
head,
|
|
||||||
base,
|
|
||||||
ahead,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the requested statuses against the checkout paths of `associations`.
|
|
||||||
fn compute_statuses(
|
|
||||||
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
|
||||||
requested: &[(RepoAddr, Option<String>)],
|
|
||||||
push_requested: &[RepoAddr],
|
|
||||||
fetch: bool,
|
|
||||||
) -> (
|
|
||||||
HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
|
||||||
HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
|
||||||
) {
|
|
||||||
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
|
||||||
for (addr, announced_head) in requested {
|
|
||||||
let Some(paths) = associations.get(addr) else {
|
|
||||||
continue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let list: Vec<CheckoutStatus> = paths
|
let ahead = repo.commits_ahead(&base, &branch);
|
||||||
.iter()
|
|
||||||
.take(MAX_STATUS_CHECKOUTS)
|
|
||||||
.filter_map(|path| checkout_status(path, announced_head.as_deref()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !list.is_empty() {
|
(ahead > 0).then_some(CheckoutStatus {
|
||||||
statuses.insert(addr.clone(), list);
|
path: path.to_path_buf(),
|
||||||
|
branch,
|
||||||
|
head,
|
||||||
|
base,
|
||||||
|
ahead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_statuses(
|
||||||
|
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
|
requested: &[(RepoAddr, Option<String>)],
|
||||||
|
push_requested: &[RepoAddr],
|
||||||
|
fetch: bool,
|
||||||
|
) -> (
|
||||||
|
HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||||
|
HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||||
|
) {
|
||||||
|
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||||
|
for (addr, announced_head) in requested {
|
||||||
|
let Some(paths) = associations.get(addr) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let list: Vec<CheckoutStatus> = paths
|
||||||
|
.iter()
|
||||||
|
.take(MAX_STATUS_CHECKOUTS)
|
||||||
|
.filter_map(|path| Self::checkout_status(path, announced_head.as_deref()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !list.is_empty() {
|
||||||
|
statuses.insert(addr.clone(), list);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||||
for addr in push_requested {
|
for addr in push_requested {
|
||||||
let Some(paths) = associations.get(addr) else {
|
let Some(paths) = associations.get(addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let list: Vec<CheckoutStatus> = paths
|
let list: Vec<CheckoutStatus> = paths
|
||||||
.iter()
|
.iter()
|
||||||
.take(MAX_STATUS_CHECKOUTS)
|
.take(MAX_STATUS_CHECKOUTS)
|
||||||
.filter_map(|path| checkout_push_status(path, fetch))
|
.filter_map(|path| Self::checkout_push_status(path, fetch))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !list.is_empty() {
|
if !list.is_empty() {
|
||||||
push_statuses.insert(addr.clone(), list);
|
push_statuses.insert(addr.clone(), list);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(statuses, push_statuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
(statuses, push_statuses)
|
pub fn pr_proposes_checkout(
|
||||||
}
|
pr: &Event,
|
||||||
|
open: bool,
|
||||||
|
user: PublicKey,
|
||||||
|
checkout: &CheckoutStatus,
|
||||||
|
) -> bool {
|
||||||
|
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pr_proposes_checkout(
|
let branch_matches = pr
|
||||||
pr: &Event,
|
.tags
|
||||||
open: bool,
|
.iter()
|
||||||
user: PublicKey,
|
.find(|t| t.kind() == "branch-name")
|
||||||
checkout: &CheckoutStatus,
|
.and_then(|t| t.content())
|
||||||
) -> bool {
|
.is_some_and(|name| name == checkout.branch);
|
||||||
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
|
||||||
return false;
|
// A renamed branch falls back to the proposed tip commit.
|
||||||
|
let tip_matches = pr
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.kind() == "c")
|
||||||
|
.and_then(|t| t.content())
|
||||||
|
.is_some_and(|tip| tip == checkout.head);
|
||||||
|
|
||||||
|
branch_matches || tip_matches
|
||||||
}
|
}
|
||||||
|
|
||||||
let branch_matches = pr
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.kind() == "branch-name")
|
|
||||||
.and_then(|t| t.content())
|
|
||||||
.is_some_and(|name| name == checkout.branch);
|
|
||||||
|
|
||||||
// A renamed branch falls back to the proposed tip commit.
|
|
||||||
let tip_matches = pr
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.kind() == "c")
|
|
||||||
.and_then(|t| t.content())
|
|
||||||
.is_some_and(|tip| tip == checkout.head);
|
|
||||||
|
|
||||||
branch_matches || tip_matches
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use signed_core::{RepoAddr, repo_addr};
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn same_repo_url_ignores_the_transport_scheme() {
|
fn same_repo_url_ignores_the_transport_scheme() {
|
||||||
// grasp announce vs https origin, with and without `.git`.
|
|
||||||
assert!(same_repo_url(
|
assert!(same_repo_url(
|
||||||
"grasp://relay.ngit.dev/npub1test/repo",
|
"grasp://relay.ngit.dev/npub1test/repo",
|
||||||
"https://relay.ngit.dev/npub1test/repo.git"
|
"https://relay.ngit.dev/npub1test/repo.git"
|
||||||
@@ -730,7 +659,6 @@ mod tests {
|
|||||||
"ws://localhost:8080/npub1test/repo",
|
"ws://localhost:8080/npub1test/repo",
|
||||||
"http://localhost:8080/npub1test/repo"
|
"http://localhost:8080/npub1test/repo"
|
||||||
));
|
));
|
||||||
// The port and the path matter.
|
|
||||||
assert!(!same_repo_url(
|
assert!(!same_repo_url(
|
||||||
"wss://localhost:8081/npub1test/repo",
|
"wss://localhost:8081/npub1test/repo",
|
||||||
"wss://localhost:8080/npub1test/repo"
|
"wss://localhost:8080/npub1test/repo"
|
||||||
@@ -739,113 +667,15 @@ mod tests {
|
|||||||
"wss://host/npub1test/repo",
|
"wss://host/npub1test/repo",
|
||||||
"wss://host/npub1other/repo"
|
"wss://host/npub1other/repo"
|
||||||
));
|
));
|
||||||
// Unparseable URLs compare literally.
|
|
||||||
assert!(same_repo_url("/local/path", "/local/path"));
|
assert!(same_repo_url("/local/path", "/local/path"));
|
||||||
assert!(!same_repo_url("/local/path", "/local/other"));
|
assert!(!same_repo_url("/local/path", "/local/other"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remembered(path: &str, id: &str, last_used: u64) -> Remembered {
|
|
||||||
Remembered {
|
|
||||||
path: PathBuf::from(path),
|
|
||||||
addr: addr(id),
|
|
||||||
last_used,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scanned(
|
|
||||||
path: &str,
|
|
||||||
origin: Option<&str>,
|
|
||||||
root: Option<&str>,
|
|
||||||
) -> (PathBuf, Option<String>, Option<String>) {
|
|
||||||
(
|
|
||||||
PathBuf::from(path),
|
|
||||||
origin.map(str::to_owned),
|
|
||||||
root.map(str::to_owned),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
|
||||||
|
|
||||||
fn owner() -> PublicKey {
|
|
||||||
Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn addr(id: &str) -> RepoAddr {
|
|
||||||
repo_addr(owner(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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")];
|
|
||||||
for url in clones {
|
|
||||||
tags.push(Tag::parse(vec!["clone", *url]).expect("tag"));
|
|
||||||
}
|
|
||||||
if let Some(euc) = euc {
|
|
||||||
tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag"));
|
|
||||||
}
|
|
||||||
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
||||||
.tags(tags)
|
|
||||||
.finalize(&keys)
|
|
||||||
.expect("signed");
|
|
||||||
Announcement::from_event(&event).expect("parsed")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_orders_remembered_freshest_first() {
|
|
||||||
let announcements = vec![announcement("repo", &[], None)];
|
|
||||||
let base = addr("repo");
|
|
||||||
|
|
||||||
let resolved = resolve_associations(
|
|
||||||
&[
|
|
||||||
remembered("/old", "repo", 100),
|
|
||||||
remembered("/fresh", "repo", 200),
|
|
||||||
remembered("/other", "unrelated", 300),
|
|
||||||
],
|
|
||||||
&[],
|
|
||||||
&announcements,
|
|
||||||
);
|
|
||||||
|
|
||||||
let paths = resolved.get(&base).expect("associations");
|
|
||||||
assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]);
|
|
||||||
// Records for repositories without announcements stay inert.
|
|
||||||
assert_eq!(resolved.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_deduplicates_paths_remembering_first() {
|
|
||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
|
||||||
let announcements = vec![announcement(
|
|
||||||
"repo",
|
|
||||||
&["https://host/npub1x/repo.git"],
|
|
||||||
Some(euc),
|
|
||||||
)];
|
|
||||||
let base = addr("repo");
|
|
||||||
|
|
||||||
// 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)],
|
|
||||||
&[
|
|
||||||
scanned("/shared", Some("https://host/npub1x/repo"), None),
|
|
||||||
scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None),
|
|
||||||
],
|
|
||||||
&announcements,
|
|
||||||
);
|
|
||||||
|
|
||||||
let paths = resolved.get(&base).expect("associations");
|
|
||||||
assert_eq!(
|
|
||||||
paths,
|
|
||||||
&vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checkout_status_reports_ahead_branches_only() {
|
fn checkout_status_reports_ahead_branches_only() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let path = dir.path().join("repo");
|
let path = dir.path().join("repo");
|
||||||
let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init");
|
let _initial = Repo::init(&path, "My Repo", "").expect("init");
|
||||||
let run = |args: &[&str]| {
|
let run = |args: &[&str]| {
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
.current_dir(&path)
|
.current_dir(&path)
|
||||||
@@ -864,35 +694,28 @@ mod tests {
|
|||||||
run(&["commit", "-m", message]);
|
run(&["commit", "-m", message]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// A feature branch ahead of main, ready to contribute.
|
|
||||||
run(&["checkout", "-b", "feature"]);
|
run(&["checkout", "-b", "feature"]);
|
||||||
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
|
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
|
||||||
commit("feature work");
|
commit("feature work");
|
||||||
let status = checkout_status(&path, Some("main")).expect("status");
|
let status = CheckoutsStore::checkout_status(&path, Some("main")).expect("status");
|
||||||
assert_eq!(status.branch, "feature");
|
assert_eq!(status.branch, "feature");
|
||||||
assert_eq!(status.base, "main");
|
assert_eq!(status.base, "main");
|
||||||
assert_eq!(status.ahead, 1);
|
assert_eq!(status.ahead, 1);
|
||||||
assert_eq!(status.head.len(), 40);
|
assert_eq!(status.head.len(), 40);
|
||||||
|
|
||||||
// Dirty worktrees are never suggested.
|
|
||||||
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
|
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
|
||||||
assert!(checkout_status(&path, Some("main")).is_none());
|
assert!(CheckoutsStore::checkout_status(&path, Some("main")).is_none());
|
||||||
run(&["checkout", "--", "."]);
|
run(&["checkout", "--", "."]);
|
||||||
|
|
||||||
// Even on main, nothing to propose.
|
|
||||||
run(&["checkout", "main"]);
|
run(&["checkout", "main"]);
|
||||||
assert_eq!(checkout_status(&path, Some("main")), None);
|
assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checkout_push_status_counts_unpushed_commits_only() {
|
fn checkout_push_status_counts_unpushed_commits_only() {
|
||||||
// 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 dir = tempfile::tempdir().expect("tempdir");
|
||||||
let remote = dir.path().join("remote");
|
let remote = dir.path().join("remote");
|
||||||
signed_git::init_repository(&remote, "My Repo", "").expect("init");
|
Repo::init(&remote, "My Repo", "").expect("init");
|
||||||
let config = Command::new("git")
|
let config = Command::new("git")
|
||||||
.args(["config", "receive.denyCurrentBranch", "ignore"])
|
.args(["config", "receive.denyCurrentBranch", "ignore"])
|
||||||
.current_dir(&remote)
|
.current_dir(&remote)
|
||||||
@@ -927,29 +750,23 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A fresh clone has nothing to push.
|
// A fresh clone has nothing to push.
|
||||||
assert_eq!(checkout_push_status(&checkout, true), None);
|
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
|
||||||
|
|
||||||
// One local commit, ready to push, counted against the remote.
|
|
||||||
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
||||||
run(&["add", "-A"]);
|
run(&["add", "-A"]);
|
||||||
run(&["commit", "-m", "local work"]);
|
run(&["commit", "-m", "local work"]);
|
||||||
let status = checkout_push_status(&checkout, true).expect("status");
|
let status = CheckoutsStore::checkout_push_status(&checkout, true).expect("status");
|
||||||
assert_eq!(status.branch, "main");
|
assert_eq!(status.branch, "main");
|
||||||
assert_eq!(status.base, "refs/remotes/origin/main");
|
assert_eq!(status.base, "refs/remotes/origin/main");
|
||||||
assert_eq!(status.ahead, 1);
|
assert_eq!(status.ahead, 1);
|
||||||
assert_eq!(status.head.len(), 40);
|
assert_eq!(status.head.len(), 40);
|
||||||
|
|
||||||
// The local-only pass reads the tracking refs, no fetch needed:
|
let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status");
|
||||||
// a commit lands locally long before the remote is reconciled.
|
|
||||||
let local = checkout_push_status(&checkout, false).expect("local status");
|
|
||||||
assert_eq!(local.ahead, 1);
|
assert_eq!(local.ahead, 1);
|
||||||
|
|
||||||
// After the push the same commit is on the remote, idle again.
|
|
||||||
run(&["push", "origin", "main"]);
|
run(&["push", "origin", "main"]);
|
||||||
assert_eq!(checkout_push_status(&checkout, true), None);
|
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
|
||||||
|
|
||||||
// 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 remote_run = |args: &[&str]| {
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
.current_dir(&remote)
|
.current_dir(&remote)
|
||||||
@@ -965,6 +782,6 @@ mod tests {
|
|||||||
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
|
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
|
||||||
remote_run(&["add", "-A"]);
|
remote_run(&["add", "-A"]);
|
||||||
remote_run(&["commit", "-m", "remote work"]);
|
remote_run(&["commit", "-m", "remote work"]);
|
||||||
assert_eq!(checkout_push_status(&checkout, true), None);
|
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,40 +2,40 @@ use std::path::PathBuf;
|
|||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use gix::Repository;
|
use nostr::prelude::Url;
|
||||||
use signed_core::RepoAddr;
|
use signed_core::RepoAddr;
|
||||||
use signed_git::GitCache;
|
use signed_git::{GitCache, Repo};
|
||||||
|
|
||||||
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
|
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
|
||||||
|
|
||||||
fn git_cache() -> &'static GitCache {
|
pub struct Mirrors;
|
||||||
GIT_CACHE
|
|
||||||
.get()
|
|
||||||
.expect("git cache is initialized by signed_state::init")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The root directory of the repository mirrors.
|
impl Mirrors {
|
||||||
pub(crate) fn repo_mirror_root() -> PathBuf {
|
pub fn install(root: impl Into<PathBuf>) {
|
||||||
git_cache().root().to_path_buf()
|
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
|
||||||
}
|
log::warn!("git cache root is already set, keeping the first one");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The on-disk path of the mirror of `addr`.
|
fn cache() -> &'static GitCache {
|
||||||
pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf {
|
GIT_CACHE
|
||||||
git_cache().repo_path(addr)
|
.get()
|
||||||
}
|
.expect("git cache is initialized by signed_state::init")
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the mirror of `addr`, if it has been cloned.
|
pub(crate) fn root() -> PathBuf {
|
||||||
pub fn open_repo_mirror(addr: &RepoAddr) -> Result<Option<Repository>> {
|
Self::cache().root().to_path_buf()
|
||||||
git_cache().open(addr)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the mirror of `addr`, cloning it first when it does not exist yet.
|
pub fn path(addr: &RepoAddr) -> PathBuf {
|
||||||
pub fn ensure_repo_mirror<U: AsRef<str>>(addr: &RepoAddr, clone_urls: &[U]) -> Result<Repository> {
|
Self::cache().repo_path(addr)
|
||||||
git_cache().ensure_clone(addr, clone_urls)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_git_cache(root: impl Into<PathBuf>) {
|
pub fn open(addr: &RepoAddr) -> Result<Option<Repo>> {
|
||||||
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
|
Self::cache().open(addr)
|
||||||
log::warn!("git cache root is already set, keeping the first one");
|
}
|
||||||
|
|
||||||
|
pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result<Repo> {
|
||||||
|
Self::cache().ensure_clone(addr, clone_urls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-160
@@ -3,11 +3,10 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{AppContext, Context, Task};
|
use gpui::{AppContext, Context, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
|
use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox};
|
||||||
|
|
||||||
use crate::backend::Backend;
|
use crate::backend::Backend;
|
||||||
|
|
||||||
/// The user's persisted inbox read state.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Inbox {
|
pub struct Inbox {
|
||||||
state: InboxReadState,
|
state: InboxReadState,
|
||||||
@@ -15,7 +14,6 @@ pub struct Inbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Inbox {
|
impl Inbox {
|
||||||
/// The current read/archive cutoffs.
|
|
||||||
pub fn state(&self) -> &InboxReadState {
|
pub fn state(&self) -> &InboxReadState {
|
||||||
&self.state
|
&self.state
|
||||||
}
|
}
|
||||||
@@ -24,41 +22,6 @@ impl Inbox {
|
|||||||
self.loaded
|
self.loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_read(
|
|
||||||
&mut self,
|
|
||||||
group: &[Event],
|
|
||||||
all: &[Event],
|
|
||||||
me: PublicKey,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
for event in group {
|
|
||||||
self.state.mark_read(event);
|
|
||||||
}
|
|
||||||
self.state.advance_read(all, me, Timestamp::now());
|
|
||||||
self.persist(cx);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Archived events are always read too.
|
|
||||||
pub fn mark_archived(
|
|
||||||
&mut self,
|
|
||||||
group: &[Event],
|
|
||||||
all: &[Event],
|
|
||||||
me: PublicKey,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
for event in group {
|
|
||||||
self.state.mark_archived(event);
|
|
||||||
self.state.mark_read(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Timestamp::now();
|
|
||||||
self.state.advance_archived(all, me, now);
|
|
||||||
self.state.advance_read(all, me, now);
|
|
||||||
self.persist(cx);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
||||||
self.state.mark_all_read(all, me, Timestamp::now());
|
self.state.mark_all_read(all, me, Timestamp::now());
|
||||||
self.persist(cx);
|
self.persist(cx);
|
||||||
@@ -71,7 +34,7 @@ impl Inbox {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let work = cx.background_spawn(async move { load_state(&client, me).await });
|
let work = cx.background_spawn(async move { Self::load_state(&client, me).await });
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let loaded = work.await;
|
let loaded = work.await;
|
||||||
@@ -102,7 +65,6 @@ impl Inbox {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign the state with a random key and store it locally.
|
|
||||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let (me, client) = {
|
let (me, client) = {
|
||||||
@@ -117,7 +79,7 @@ impl Inbox {
|
|||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
if let Err(error) = save_state(&client, me, &state).await {
|
if let Err(error) = Self::save_state(&client, me, &state).await {
|
||||||
log::warn!("failed to save inbox state: {error}");
|
log::warn!("failed to save inbox state: {error}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -125,135 +87,144 @@ impl Inbox {
|
|||||||
|
|
||||||
task.detach();
|
task.detach();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn query_inbox(
|
pub async fn query(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
me: PublicKey,
|
me: PublicKey,
|
||||||
state: &InboxReadState,
|
state: &InboxReadState,
|
||||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
) -> Result<(Vec<InboxItem>, usize), Error> {
|
||||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
let deletion_events = client.database().query(Filters::deletions()).await?;
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
|
let (notification_events, mut by_id) =
|
||||||
|
Self::fetch_notifications(client, me, &deletions).await?;
|
||||||
|
|
||||||
let mut activity = Vec::new();
|
let mut activity = Vec::new();
|
||||||
for event in client
|
for event in client
|
||||||
.database()
|
|
||||||
.query(filters::authored_activity(me))
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
|
||||||
activity.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
|
||||||
by_id.get(&id).cloned()
|
|
||||||
});
|
|
||||||
|
|
||||||
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
|
||||||
|
|
||||||
Ok((items, unread_count))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `d` tag identifying the inbox state event of `me`.
|
|
||||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
|
||||||
format!("signed-inbox-state:{}", me.to_hex())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifier(inbox_state_d_tag(me));
|
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
match serde_json::from_str(&event.content) {
|
|
||||||
Ok(state) => Ok(Some(state)),
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sign with a random key and store locally.
|
|
||||||
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
|
||||||
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
|
||||||
.finalize(&Keys::generate())?;
|
|
||||||
|
|
||||||
client.database().save_event(&event).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notification events and a lookup of every ancestor they reference.
|
|
||||||
async fn fetch_notifications(
|
|
||||||
client: &Client,
|
|
||||||
me: PublicKey,
|
|
||||||
deletions: &Deletions,
|
|
||||||
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
|
||||||
let mut notifications: Vec<Event> = Vec::new();
|
|
||||||
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
|
||||||
|
|
||||||
for filter in filters::notifications(me) {
|
|
||||||
for event in client.database().query(filter).await? {
|
|
||||||
if deletions.is_deleted(&event) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if by_id.insert(event.id, event.clone()).is_none() {
|
|
||||||
notifications.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
|
|
||||||
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
pending.retain(|id| seen.insert(*id));
|
|
||||||
|
|
||||||
if pending.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ancestors = client
|
|
||||||
.database()
|
.database()
|
||||||
.query(Filter::new().ids(pending.iter().copied()))
|
.query(Filters::authored_activity(me))
|
||||||
.await?;
|
.await?
|
||||||
|
{
|
||||||
let mut next = Vec::new();
|
if deletions.is_deleted(&event) || !event.is_git_activity() {
|
||||||
|
|
||||||
for event in ancestors {
|
|
||||||
if deletions.is_deleted(&event) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next.extend(event_references(&event).filter(|id| !seen.contains(id)));
|
by_id.entry(event.id).or_insert_with(|| event.clone());
|
||||||
by_id.entry(event.id).or_insert(event);
|
activity.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
pending = next;
|
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
||||||
|
by_id.get(&id).cloned()
|
||||||
|
});
|
||||||
|
|
||||||
|
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||||
|
|
||||||
|
Ok((items, unread_count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||||
|
format!("signed-inbox-state:{}", me.to_hex())
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((notifications, by_id))
|
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||||
}
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(Self::inbox_state_d_tag(me));
|
||||||
|
|
||||||
/// Event ids referenced by `event` through its `e` and `E` tags.
|
let events = client.database().query(filter).await?;
|
||||||
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
|
||||||
event.tags.iter().filter_map(|tag| {
|
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||||
if tag.kind() != "e" && tag.kind() != "E" {
|
return Ok(None);
|
||||||
return None;
|
};
|
||||||
|
|
||||||
|
match serde_json::from_str(&event.content) {
|
||||||
|
Ok(state) => Ok(Some(state)),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tag.content()
|
}
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
})
|
// Signed with a random key: the state is local-only, authorship does not
|
||||||
|
// matter and no key material needs to be kept.
|
||||||
|
async fn save_state(
|
||||||
|
client: &Client,
|
||||||
|
me: PublicKey,
|
||||||
|
state: &InboxReadState,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||||
|
.tags([Tag::identifier(Self::inbox_state_d_tag(me))])
|
||||||
|
.finalize(&Keys::generate())?;
|
||||||
|
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_notifications(
|
||||||
|
client: &Client,
|
||||||
|
me: PublicKey,
|
||||||
|
deletions: &Deletions,
|
||||||
|
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
||||||
|
let mut notifications: Vec<Event> = Vec::new();
|
||||||
|
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||||
|
|
||||||
|
for filter in Filters::notifications(me) {
|
||||||
|
for event in client.database().query(filter).await? {
|
||||||
|
if deletions.is_deleted(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if by_id.insert(event.id, event.clone()).is_none() {
|
||||||
|
notifications.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pending: Vec<EventId> = notifications
|
||||||
|
.iter()
|
||||||
|
.flat_map(Self::event_references)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
pending.retain(|id| seen.insert(*id));
|
||||||
|
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ancestors = client
|
||||||
|
.database()
|
||||||
|
.query(Filter::new().ids(pending.iter().copied()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut next = Vec::new();
|
||||||
|
|
||||||
|
for event in ancestors {
|
||||||
|
if deletions.is_deleted(&event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.extend(Self::event_references(&event).filter(|id| !seen.contains(id)));
|
||||||
|
by_id.entry(event.id).or_insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((notifications, by_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
||||||
|
event.tags.iter().filter_map(|tag| {
|
||||||
|
if tag.kind() != "e" && tag.kind() != "E" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
|
mod bootstrap;
|
||||||
mod checkouts;
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
mod inbox;
|
mod inbox;
|
||||||
mod local_repos;
|
mod local_repos;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod push;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
mod repos;
|
mod repos;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
pub use backend::{Backend, BackendEvent};
|
||||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
pub use bootstrap::user_grasp_list_servers;
|
||||||
use git_store::set_git_cache;
|
pub use checkouts::{CheckoutStatus, CheckoutsStore};
|
||||||
pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path};
|
pub use git_store::Mirrors;
|
||||||
use gpui::{App, AppContext};
|
use gpui::{App, AppContext};
|
||||||
pub use inbox::{Inbox, query_inbox};
|
pub use inbox::Inbox;
|
||||||
pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos};
|
pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
|
pub use push::{GraspServer, PushOutcome};
|
||||||
pub use refresh::{RefreshGate, RefreshRequest};
|
pub use refresh::{RefreshGate, RefreshRequest};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repos::{RepoActivityCounts, RepoListStore};
|
pub use repos::RepoListStore;
|
||||||
pub use signed_git::{GraspSignals, LocalRepo, Nip34Binding, Nip34Kind};
|
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::NostrBackend;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
db_path: impl AsRef<Path>,
|
db_path: impl AsRef<Path>,
|
||||||
repos_root: impl Into<PathBuf>,
|
repos_root: impl Into<PathBuf>,
|
||||||
@@ -35,41 +37,17 @@ pub fn init(
|
|||||||
// rustls uses the `aws_lc_rs` provider by default.
|
// rustls uses the `aws_lc_rs` provider by default.
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
// Initialize the nostr client and signer
|
let backend = cx
|
||||||
let (client, signer) = cx.foreground_executor().block_on(async move {
|
.foreground_executor()
|
||||||
let path = db_path.as_ref().to_path_buf();
|
.block_on(async move { NostrBackend::open(db_path.as_ref()).await });
|
||||||
new_backend(path)
|
let backend = backend.expect("failed to initialize nostr backend");
|
||||||
.await
|
let (client, signer) = (backend.client, backend.signer);
|
||||||
.expect("failed to initialize nostr backend")
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set Git cache for the repos root
|
Mirrors::install(repos_root);
|
||||||
set_git_cache(repos_root);
|
|
||||||
|
|
||||||
// Set global stores for the backend
|
|
||||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
|
|
||||||
// Set global stores for the profile
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
// Set global stores for the repo list and local repos
|
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
|
|
||||||
// Set global stores for the local repos
|
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
|
|
||||||
// Set global stores for the checkouts
|
|
||||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the backend with an in-memory database on wasm.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
|
||||||
set_git_cache(PathBuf::new());
|
|
||||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
|
||||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||||
use signed_core::{Announcement, RepoAddr, repo_addr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
||||||
|
|
||||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||||
|
|
||||||
impl Global for GlobalLocalReposStore {}
|
impl Global for GlobalLocalReposStore {}
|
||||||
|
|
||||||
/// Store of the git repositories discovered under a set of scan paths.
|
|
||||||
pub struct LocalReposStore {
|
pub struct LocalReposStore {
|
||||||
pub roots: Arc<Vec<PathBuf>>,
|
pub roots: Arc<Vec<PathBuf>>,
|
||||||
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
|
||||||
pub repos: Arc<Vec<LocalRepo>>,
|
pub repos: Arc<Vec<LocalRepo>>,
|
||||||
pub scanning: bool,
|
pub scanning: bool,
|
||||||
scan_dirty: bool,
|
scan_dirty: bool,
|
||||||
@@ -45,7 +43,6 @@ impl LocalReposStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget a repository that has just been published to NIP-34.
|
|
||||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||||
self.repos = Arc::new(
|
self.repos = Arc::new(
|
||||||
self.repos
|
self.repos
|
||||||
@@ -94,7 +91,6 @@ impl LocalReposStore {
|
|||||||
dirty
|
dirty
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
|
||||||
if again {
|
if again {
|
||||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||||
}
|
}
|
||||||
@@ -106,28 +102,22 @@ impl LocalReposStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The NIP-34 coordinate a repository's detection resolved, when both the owner
|
|
||||||
/// and the identifier were recovered.
|
|
||||||
pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
|
pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
|
||||||
let binding = repo.nip34.as_ref()?;
|
let binding = repo.nip34.as_ref()?;
|
||||||
let owner = binding.owner?;
|
let owner = binding.owner?;
|
||||||
let identifier = binding.identifier.as_deref()?;
|
let identifier = binding.identifier.as_deref()?;
|
||||||
|
|
||||||
Some(repo_addr(owner, identifier))
|
Some(RepoAddr::new(owner, identifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A scanned repository resolved against the known announcements.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ResolvedLocalRepo {
|
pub struct ResolvedLocalRepo {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// `None` for a plain repository.
|
|
||||||
pub nip34: Option<Nip34Binding>,
|
pub nip34: Option<Nip34Binding>,
|
||||||
/// The known announcement this repository is bound to, when one matched.
|
|
||||||
pub announcement: Option<Announcement>,
|
pub announcement: Option<Announcement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedLocalRepo {
|
impl ResolvedLocalRepo {
|
||||||
/// The repository's directory name, or `Untitled` when the path has none.
|
|
||||||
pub fn name(&self) -> SharedString {
|
pub fn name(&self) -> SharedString {
|
||||||
self.path
|
self.path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -136,41 +126,42 @@ impl ResolvedLocalRepo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the scanned repositories against the known announcements.
|
impl LocalReposStore {
|
||||||
pub fn resolve_local_repos(
|
pub fn resolve(
|
||||||
repos: &[LocalRepo],
|
repos: &[LocalRepo],
|
||||||
known: &[Announcement],
|
known: &[Announcement],
|
||||||
own: &[Announcement],
|
own: &[Announcement],
|
||||||
) -> Vec<ResolvedLocalRepo> {
|
) -> Vec<ResolvedLocalRepo> {
|
||||||
let shown: HashSet<RepoAddr> = own.iter().map(Announcement::addr).collect();
|
let shown: HashSet<RepoAddr> = own.iter().map(Announcement::addr).collect();
|
||||||
|
|
||||||
repos
|
repos
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|repo| {
|
.filter_map(|repo| {
|
||||||
let addr = local_repo_addr(repo);
|
let addr = local_repo_addr(repo);
|
||||||
|
|
||||||
if let Some(addr) = &addr
|
if let Some(addr) = &addr
|
||||||
&& shown.contains(addr)
|
&& shown.contains(addr)
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let announcement = addr
|
let announcement = addr
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|addr| {
|
.and_then(|addr| {
|
||||||
known
|
known
|
||||||
.iter()
|
.iter()
|
||||||
.find(|announcement| announcement.addr() == *addr)
|
.find(|announcement| announcement.addr() == *addr)
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
Some(ResolvedLocalRepo {
|
||||||
|
path: repo.path.clone(),
|
||||||
|
nip34: repo.nip34.clone(),
|
||||||
|
announcement,
|
||||||
})
|
})
|
||||||
.cloned();
|
|
||||||
|
|
||||||
Some(ResolvedLocalRepo {
|
|
||||||
path: repo.path.clone(),
|
|
||||||
nip34: repo.nip34.clone(),
|
|
||||||
announcement,
|
|
||||||
})
|
})
|
||||||
})
|
.collect()
|
||||||
.collect()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -222,7 +213,7 @@ mod tests {
|
|||||||
let repo = bound(KEY, "mine");
|
let repo = bound(KEY, "mine");
|
||||||
|
|
||||||
let own = std::slice::from_ref(&own);
|
let own = std::slice::from_ref(&own);
|
||||||
assert!(resolve_local_repos(&[repo], own, own).is_empty());
|
assert!(LocalReposStore::resolve(&[repo], own, own).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -230,61 +221,9 @@ mod tests {
|
|||||||
let known = announcement(OTHER_KEY, "theirs");
|
let known = announcement(OTHER_KEY, "theirs");
|
||||||
let repo = bound(OTHER_KEY, "theirs");
|
let repo = bound(OTHER_KEY, "theirs");
|
||||||
|
|
||||||
let resolved = resolve_local_repos(&[repo], std::slice::from_ref(&known), &[]);
|
let resolved = LocalReposStore::resolve(&[repo], std::slice::from_ref(&known), &[]);
|
||||||
|
|
||||||
assert_eq!(resolved.len(), 1);
|
assert_eq!(resolved.len(), 1);
|
||||||
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
|
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_unmatched_repository_keeps_its_binding() {
|
|
||||||
let repo = bound(KEY, "unlisted");
|
|
||||||
|
|
||||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
|
||||||
|
|
||||||
assert_eq!(resolved.len(), 1);
|
|
||||||
assert!(resolved[0].announcement.is_none());
|
|
||||||
assert_eq!(
|
|
||||||
resolved[0].nip34.as_ref().map(|binding| binding.kind),
|
|
||||||
Some(Nip34Kind::Initialized)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_plain_repository_is_kept_without_a_binding() {
|
|
||||||
let repo = LocalRepo {
|
|
||||||
path: PathBuf::from("plain"),
|
|
||||||
nip34: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
|
||||||
|
|
||||||
assert_eq!(resolved.len(), 1);
|
|
||||||
assert!(resolved[0].nip34.is_none());
|
|
||||||
assert!(resolved[0].announcement.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_name_is_the_directory_name() {
|
|
||||||
let repo = LocalRepo {
|
|
||||||
path: PathBuf::from("/tmp/my-repo"),
|
|
||||||
nip34: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
|
||||||
|
|
||||||
assert_eq!(resolved[0].name(), SharedString::from("my-repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_path_without_a_directory_name_is_untitled() {
|
|
||||||
let repo = LocalRepo {
|
|
||||||
path: PathBuf::from("/"),
|
|
||||||
nip34: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
|
||||||
|
|
||||||
assert_eq!(resolved[0].name(), SharedString::from("Untitled"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ use gpui::{
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use utils::shorten_pubkey;
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
use crate::bootstrap::sync_bootstrap_only;
|
||||||
|
|
||||||
/// How long to wait for more requests before firing a batched fetch.
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
/// Max authors per profile request, keeping each filter within relay limits.
|
|
||||||
const REQUEST_CHUNK: usize = 100;
|
const REQUEST_CHUNK: usize = 100;
|
||||||
/// Recent profiles prefetched at startup and read back from the cache.
|
|
||||||
const WARM_LIMIT: usize = 500;
|
const WARM_LIMIT: usize = 500;
|
||||||
|
|
||||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
@@ -42,7 +39,7 @@ impl Profile {
|
|||||||
&self.metadata
|
&self.metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display name, falling back to `name`, then a shortened npub.
|
// Falls back to `name`, then a shortened npub.
|
||||||
pub fn name(&self) -> SharedString {
|
pub fn name(&self) -> SharedString {
|
||||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||||
&& !display_name.is_empty()
|
&& !display_name.is_empty()
|
||||||
@@ -68,14 +65,9 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global profile cache.
|
|
||||||
///
|
|
||||||
/// Profiles are fetched in batches and kept as plain data.
|
|
||||||
pub struct ProfileStore {
|
pub struct ProfileStore {
|
||||||
profiles: HashMap<PublicKey, Profile>,
|
profiles: HashMap<PublicKey, Profile>,
|
||||||
/// Public keys requested this session, main thread only.
|
|
||||||
seen: RefCell<HashSet<PublicKey>>,
|
seen: RefCell<HashSet<PublicKey>>,
|
||||||
/// Sender for queuing fetch requests, batched by a background task.
|
|
||||||
sender: Sender<PublicKey>,
|
sender: Sender<PublicKey>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
@@ -125,9 +117,7 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a profile.
|
// Returns a placeholder until fetched; queues a fetch when uncached.
|
||||||
///
|
|
||||||
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
|
|
||||||
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||||
if let Some(profile) = self.profiles.get(public_key) {
|
if let Some(profile) = self.profiles.get(public_key) {
|
||||||
return profile.clone();
|
return profile.clone();
|
||||||
@@ -178,7 +168,6 @@ impl ProfileStore {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of `authors` from the local database in one query.
|
|
||||||
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||||
if authors.is_empty() {
|
if authors.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -231,13 +220,11 @@ impl ProfileStore {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local database.
|
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||||
self.apply_authors(authors, cx);
|
self.apply_authors(authors, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch metadata for requested authors in batches, debounced to collect requests.
|
|
||||||
async fn handle_requests(
|
async fn handle_requests(
|
||||||
this: WeakEntity<ProfileStore>,
|
this: WeakEntity<ProfileStore>,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Error, bail};
|
||||||
|
use gpui::BackgroundExecutor;
|
||||||
|
use nostr::event::IntoEventBuilder;
|
||||||
|
use nostr::prelude::Url;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_core::RepoState;
|
||||||
|
use signed_git::Repo;
|
||||||
|
use signed_nostr::UniversalSigner;
|
||||||
|
|
||||||
|
pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
|
const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
// `ws://` grasp servers, like ngit, use `http://<host>`; secure relays map to
|
||||||
|
// `https://<host>`.
|
||||||
|
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||||
|
let parsed = Url::parse(relay.as_str()).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||||
|
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
|
||||||
|
let scheme = if relay.scheme().is_secure() {
|
||||||
|
"https"
|
||||||
|
} else {
|
||||||
|
"http"
|
||||||
|
};
|
||||||
|
Some(format!("{scheme}://{host}{port}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
|
||||||
|
let base = grasp_base_url(relay)?;
|
||||||
|
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GRASP-06 contributor namespace URL of a pull request tip.
|
||||||
|
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
||||||
|
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The author's GRASP-06 `/prs/` URLs come first.
|
||||||
|
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
for url in prs_urls.into_iter().chain(base_clone_urls) {
|
||||||
|
if seen.insert(url.to_string()) {
|
||||||
|
urls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
urls
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GraspServer {
|
||||||
|
relay: RelayUrl,
|
||||||
|
reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GraspServer {
|
||||||
|
fn ok(relay: RelayUrl) -> Self {
|
||||||
|
Self {
|
||||||
|
relay,
|
||||||
|
reason: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
relay,
|
||||||
|
reason: Some(reason.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn relay(&self) -> &RelayUrl {
|
||||||
|
&self.relay
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reason(&self) -> Option<&str> {
|
||||||
|
self.reason.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PushOutcome {
|
||||||
|
pub servers: Vec<GraspServer>,
|
||||||
|
// The newest state event a grasp relay accepted for this push; broadcast
|
||||||
|
// to the other relays once a git server holds the data.
|
||||||
|
pub state_event: Option<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PushOutcome {
|
||||||
|
pub fn accepted(&self) -> usize {
|
||||||
|
self.servers
|
||||||
|
.iter()
|
||||||
|
.filter(|server| server.reason.is_none())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failing(&self) -> impl Iterator<Item = &GraspServer> {
|
||||||
|
self.servers.iter().filter(|server| server.reason.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failure_summary(&self) -> String {
|
||||||
|
self.failing()
|
||||||
|
.map(|server| {
|
||||||
|
let reason =
|
||||||
|
utils::flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error"));
|
||||||
|
format!("{}: {reason}", server.relay)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// `None` when every server accepted the push or nothing was pushed.
|
||||||
|
pub fn partial_warning(&self) -> Option<String> {
|
||||||
|
let accepted = self.accepted();
|
||||||
|
if self.servers.is_empty() || accepted == self.servers.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!(
|
||||||
|
"Pushed to {accepted} of {} grasp servers: {}. Republish to sync.",
|
||||||
|
self.servers.len(),
|
||||||
|
self.failure_summary()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reasons a push attempt should be retried with a freshly staged state event
|
||||||
|
// and a fresh git advertisement. Two families are retried:
|
||||||
|
//
|
||||||
|
// - **Purgatory denials**: the grasp server sends these when the state event
|
||||||
|
// for the push has not reached its purgatory yet. Re-staging a fresh event
|
||||||
|
// resolves them.
|
||||||
|
// - **Stale advertisement races**: `git receive-pack` compares each ref
|
||||||
|
// update against the value it advertised when the push started. The grasp
|
||||||
|
// server's own background sync can move a ref in between - typically by
|
||||||
|
// aligning the repository to a parked state event once the objects of an
|
||||||
|
// earlier attempt land - so the compare-and-swap fails with `cannot lock
|
||||||
|
// ref` / `incorrect old value provided`. A retry against the fresh
|
||||||
|
// advertisement converges, and when the race is lost the pushed data is
|
||||||
|
// usually already on the server (see `is_stale_advertisement_race` and
|
||||||
|
// the convergence probe in `push_staged_to_grasps`).
|
||||||
|
//
|
||||||
|
// Other rejections are not retried.
|
||||||
|
fn is_transient_grasp_denial(stderr: &str) -> bool {
|
||||||
|
let error = stderr.to_lowercase();
|
||||||
|
[
|
||||||
|
"no state events in purgatory",
|
||||||
|
"no matching state event",
|
||||||
|
"doesn't match push",
|
||||||
|
"none from authorized publishers",
|
||||||
|
"no repository announcement found",
|
||||||
|
"cannot lock ref",
|
||||||
|
"incorrect old value provided",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|marker| error.contains(marker))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A push rejected because `git receive-pack`'s compare-and-swap lost to the
|
||||||
|
// grasp server's own background ref alignment: the ref moved between this
|
||||||
|
// push's advertisement and its ref transaction (`cannot lock ref ... is at
|
||||||
|
// ... but expected ...` / `incorrect old value provided`). The pushed data
|
||||||
|
// is usually already on the server by then.
|
||||||
|
fn is_stale_advertisement_race(stderr: &str) -> bool {
|
||||||
|
let error = stderr.to_lowercase();
|
||||||
|
error.contains("cannot lock ref") || error.contains("incorrect old value provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// All staged events carry the same refs; the newest timestamp wins on the relays.
|
||||||
|
fn keep_newest(state_event: &mut Option<Event>, event: Event) {
|
||||||
|
if state_event
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|current| event.created_at > current.created_at)
|
||||||
|
{
|
||||||
|
*state_event = Some(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grasp push pipeline: stage a signed state event on each server's
|
||||||
|
// relay, then push the git data, retrying transient denials.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct GraspPush {
|
||||||
|
client: Client,
|
||||||
|
signer: UniversalSigner,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GraspPush {
|
||||||
|
pub(crate) fn new(client: Client, signer: UniversalSigner) -> Self {
|
||||||
|
Self { client, signer }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn require_relay_accepted(
|
||||||
|
output: SendEventOutput,
|
||||||
|
event: Event,
|
||||||
|
) -> Result<Event, Error> {
|
||||||
|
if output.success.is_empty() && !output.failed.is_empty() {
|
||||||
|
let reasons = output
|
||||||
|
.failed
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ");
|
||||||
|
bail!("event not accepted by any relay: {reasons}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relay hiccup should not block sign-up, so failures are only logged.
|
||||||
|
pub(crate) async fn publish_best_effort(&self, builder: EventBuilder) {
|
||||||
|
let result: Result<(), Error> = async {
|
||||||
|
let event = builder.finalize_async(&self.signer).await?;
|
||||||
|
let output = self.client.send_event(&event).broadcast().await?;
|
||||||
|
Self::require_relay_accepted(output, event)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
log::warn!("failed to publish identity bootstrap event: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn publish_one(&self, builder: EventBuilder) -> Result<Event, Error> {
|
||||||
|
let event = builder.finalize_async(&self.signer).await?;
|
||||||
|
self.send_accepted(event).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn send_accepted(&self, event: Event) -> Result<Event, Error> {
|
||||||
|
let output = self.client.send_event(&event).broadcast().await?;
|
||||||
|
Self::require_relay_accepted(output, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn retract_event(&self, event: &Event) -> Result<(), Error> {
|
||||||
|
let builder = EventDeletionRequest::new()
|
||||||
|
.id(event.id)
|
||||||
|
.into_event_builder();
|
||||||
|
|
||||||
|
let deletion = builder.finalize_async(&self.signer).await?;
|
||||||
|
self.client.send_event(&deletion).broadcast().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retries within the same second get the next second: a grasp relay
|
||||||
|
// treats a same-id resend as a duplicate and does not re-run its ingest,
|
||||||
|
// so an identical resend cannot re-park a state event lost from its purgatory.
|
||||||
|
async fn sign_state_event(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
refs: &[(String, String)],
|
||||||
|
head: Option<&str>,
|
||||||
|
last_created_at: u64,
|
||||||
|
) -> Result<(Event, u64), String> {
|
||||||
|
let now = Timestamp::now().as_secs();
|
||||||
|
let created_at = if now > last_created_at {
|
||||||
|
now
|
||||||
|
} else {
|
||||||
|
last_created_at + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = RepoState::build(repo_id, refs, head)
|
||||||
|
.custom_created_at(Timestamp::from_secs(created_at))
|
||||||
|
.finalize_async(&self.signer)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("could not sign the state event: {e}"))?;
|
||||||
|
|
||||||
|
Ok((event, created_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Ok` only when the relay confirmed the event. On a grasp relay the
|
||||||
|
// accept parks the event in purgatory, which authorizes the paired git push.
|
||||||
|
async fn stage_event_on_relay(&self, relay: &RelayUrl, event: &Event) -> Result<(), String> {
|
||||||
|
self.client
|
||||||
|
.add_relay(relay)
|
||||||
|
.and_connect()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("could not add relay {relay}: {e}"))?;
|
||||||
|
|
||||||
|
let output = self
|
||||||
|
.client
|
||||||
|
.send_event(event)
|
||||||
|
.to([relay.clone()])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("could not send the state event to {relay}: {e}"))?;
|
||||||
|
|
||||||
|
if output.success.contains_key(relay) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let reason = output
|
||||||
|
.failed
|
||||||
|
.get(relay)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "relay did not confirm the event".to_owned());
|
||||||
|
Err(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) async fn push_staged_to_grasps(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
refs: &[(String, String)],
|
||||||
|
head: Option<&str>,
|
||||||
|
path: &Path,
|
||||||
|
owner: &str,
|
||||||
|
servers: &[RelayUrl],
|
||||||
|
executor: &BackgroundExecutor,
|
||||||
|
push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>,
|
||||||
|
) -> PushOutcome {
|
||||||
|
let mut outcome = PushOutcome::default();
|
||||||
|
|
||||||
|
for relay in servers {
|
||||||
|
let Some(base) = grasp_base_url(relay) else {
|
||||||
|
outcome
|
||||||
|
.servers
|
||||||
|
.push(GraspServer::failed(relay.clone(), "no domain"));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let git_url = format!("{base}/{owner}/{repo_id}.git");
|
||||||
|
|
||||||
|
let mut reason = None;
|
||||||
|
let mut last_created_at = 0;
|
||||||
|
// For the convergence probe when every push attempt lost the
|
||||||
|
// stale-ref race.
|
||||||
|
let mut staged_event = None;
|
||||||
|
|
||||||
|
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
|
||||||
|
if attempt > 1 {
|
||||||
|
executor.timer(GRASP_RETRY_DELAY).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (event, created_at) = match self
|
||||||
|
.sign_state_event(repo_id, refs, head, last_created_at)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(signed) => signed,
|
||||||
|
Err(e) => {
|
||||||
|
reason = Some(e);
|
||||||
|
break 'server;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
last_created_at = created_at;
|
||||||
|
|
||||||
|
// A failed stage means the grasp never parked the state, so
|
||||||
|
// the git push would be denied anyway: skip it.
|
||||||
|
if let Err(e) = self.stage_event_on_relay(relay, &event).await {
|
||||||
|
// One retry absorbs a relay connect blip, on the first
|
||||||
|
// attempt only.
|
||||||
|
if attempt == 1 && self.stage_event_on_relay(relay, &event).await.is_ok() {
|
||||||
|
} else {
|
||||||
|
reason = Some(e);
|
||||||
|
break 'server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
staged_event = Some(event.clone());
|
||||||
|
|
||||||
|
match push(path, &base, owner, repo_id) {
|
||||||
|
Ok(()) => {
|
||||||
|
keep_newest(&mut outcome.state_event, event);
|
||||||
|
break 'server;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let text = e.to_string();
|
||||||
|
if attempt < GRASP_PUSH_ATTEMPTS && is_transient_grasp_denial(&text) {
|
||||||
|
reason = Some(text);
|
||||||
|
continue 'server;
|
||||||
|
}
|
||||||
|
reason = Some(text);
|
||||||
|
break 'server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grasp's own background sync aligns refs to staged state
|
||||||
|
// events as soon as the objects land, which can beat every push
|
||||||
|
// attempt's compare-and-swap. When the last denial was that race
|
||||||
|
// the sync has usually finished by now: verify the advertised refs
|
||||||
|
// and accept the server when the pushed data is already there.
|
||||||
|
if let Some(last_reason) = &reason
|
||||||
|
&& is_stale_advertisement_race(last_reason)
|
||||||
|
&& Repo::open(path)
|
||||||
|
.and_then(|repo| repo.remote_has_refs(&git_url, refs))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Some(event) = staged_event {
|
||||||
|
keep_newest(&mut outcome.state_event, event);
|
||||||
|
}
|
||||||
|
reason = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match reason {
|
||||||
|
Some(reason) => {
|
||||||
|
log::warn!("grasp push failed: {relay}: {reason}");
|
||||||
|
outcome
|
||||||
|
.servers
|
||||||
|
.push(GraspServer::failed(relay.clone(), reason));
|
||||||
|
}
|
||||||
|
None => outcome.servers.push(GraspServer::ok(relay.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grasp_base_url_maps_schemes_like_ngit() {
|
||||||
|
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
|
||||||
|
assert_eq!(
|
||||||
|
grasp_base_url(&wss).as_deref(),
|
||||||
|
Some("https://relay.ngit.dev")
|
||||||
|
);
|
||||||
|
|
||||||
|
let ws = RelayUrl::parse("ws://localhost:8080").expect("url");
|
||||||
|
assert_eq!(
|
||||||
|
grasp_base_url(&ws).as_deref(),
|
||||||
|
Some("http://localhost:8080")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grasp_clone_url_matches_ngit_format() {
|
||||||
|
let relay = RelayUrl::parse("wss://gitnostr.com").expect("url");
|
||||||
|
let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url");
|
||||||
|
assert_eq!(
|
||||||
|
url.to_string(),
|
||||||
|
"https://gitnostr.com/npub1test/my-repo.git"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grasp06_prs_url_matches_ngit_format() {
|
||||||
|
assert_eq!(
|
||||||
|
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
|
||||||
|
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
|
||||||
|
"http://localhost:8080/prs/npub1author/my-repo.git"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pr_clone_urls_orders_author_first_and_deduplicates() {
|
||||||
|
let prs = vec![
|
||||||
|
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||||
|
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||||
|
];
|
||||||
|
let base = vec![
|
||||||
|
Url::parse("https://a.example/npub1owner/repo.git").expect("url"),
|
||||||
|
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||||
|
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let urls = pr_clone_urls(prs, base);
|
||||||
|
assert_eq!(
|
||||||
|
urls.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"https://a.example/prs/npub1me/repo.git",
|
||||||
|
"https://a.example/npub1owner/repo.git",
|
||||||
|
"https://b.example/npub1owner/repo.git",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_grasp_denials_are_classified() {
|
||||||
|
let reported = "remote: ERR authorisation failed: No state events in purgatory\n\
|
||||||
|
fatal: the remote end hung up unexpectedly\n\
|
||||||
|
error: failed to push some refs to 'https://relay.ngit.dev/...git'";
|
||||||
|
assert!(is_transient_grasp_denial(reported));
|
||||||
|
|
||||||
|
assert!(is_transient_grasp_denial(
|
||||||
|
"remote: ERR authorisation failed: No matching state event found in purgatory"
|
||||||
|
));
|
||||||
|
assert!(is_transient_grasp_denial(
|
||||||
|
"remote: ERR authorisation failed: 1 state event in purgatory from authorized \
|
||||||
|
publisher but doesn't match push"
|
||||||
|
));
|
||||||
|
assert!(is_transient_grasp_denial(
|
||||||
|
"remote: ERR authorisation failed: 2 state events in purgatory but none from \
|
||||||
|
authorized publishers"
|
||||||
|
));
|
||||||
|
assert!(is_transient_grasp_denial(
|
||||||
|
"remote: ERR authorisation failed: No repository announcement found"
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(!is_transient_grasp_denial(
|
||||||
|
"remote: ERR authorisation failed: not a maintainer of this repository"
|
||||||
|
));
|
||||||
|
assert!(!is_transient_grasp_denial(
|
||||||
|
"fatal: unable to access 'https://relay.ngit.dev/...': The requested URL returned \
|
||||||
|
error: 403"
|
||||||
|
));
|
||||||
|
assert!(!is_transient_grasp_denial(
|
||||||
|
"fatal: unable to access 'https://relay.ngit.dev/...': Could not resolve host"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_ref_races_are_retried() {
|
||||||
|
let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \
|
||||||
|
cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \
|
||||||
|
bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\
|
||||||
|
To https://relay.ngit.dev/npub1owner/signed-test.git\n\
|
||||||
|
! [remote rejected] main -> main (incorrect old value provided)\n\
|
||||||
|
error: failed to push some refs to 'https://relay.ngit.dev/npub1owner/signed-test.git'";
|
||||||
|
assert!(is_transient_grasp_denial(reported));
|
||||||
|
assert!(is_stale_advertisement_race(reported));
|
||||||
|
|
||||||
|
assert!(is_stale_advertisement_race(
|
||||||
|
"cannot lock ref 'refs/heads/main'"
|
||||||
|
));
|
||||||
|
assert!(is_stale_advertisement_race(
|
||||||
|
"! [remote rejected] main -> main (incorrect old value provided)"
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(!is_stale_advertisement_race("No state events in purgatory"));
|
||||||
|
|
||||||
|
assert!(!is_transient_grasp_denial(
|
||||||
|
" ! [rejected] main -> main (non-fast-forward)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/// Refresh coalescing shared by the event stores.
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct RefreshGate {
|
pub struct RefreshGate {
|
||||||
running: bool,
|
running: bool,
|
||||||
@@ -7,9 +6,7 @@ pub struct RefreshGate {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RefreshRequest {
|
pub enum RefreshRequest {
|
||||||
/// No run covers the request, start one now.
|
|
||||||
Schedule,
|
Schedule,
|
||||||
/// A run is in flight and covers the request, fold it into a follow-up.
|
|
||||||
Fold,
|
Fold,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,59 +28,13 @@ impl RefreshGate {
|
|||||||
self.running = true;
|
self.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The run ended. Whether a request arrived while it ran.
|
|
||||||
pub fn finish(&mut self) -> bool {
|
pub fn finish(&mut self) -> bool {
|
||||||
self.running = false;
|
self.running = false;
|
||||||
std::mem::take(&mut self.dirty)
|
std::mem::take(&mut self.dirty)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
|
// Pending follow-up requests survive an abandoned run.
|
||||||
pub fn abort(&mut self) {
|
pub fn abort(&mut self) {
|
||||||
self.running = false;
|
self.running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_request_while_running_folds_into_a_follow_up() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.begin();
|
|
||||||
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Fold);
|
|
||||||
assert!(gate.finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_request_without_a_run_schedules() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Schedule);
|
|
||||||
assert!(!gate.running());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_request_after_a_run_schedules_again() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.begin();
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Fold);
|
|
||||||
assert!(gate.finish());
|
|
||||||
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn abort_keeps_the_pending_request() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.begin();
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Fold);
|
|
||||||
|
|
||||||
gate.abort();
|
|
||||||
assert!(!gate.running());
|
|
||||||
|
|
||||||
gate.begin();
|
|
||||||
assert!(gate.finish());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+177
-444
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
use signed_core::{Announcement, Deletions, Filters, RepoAddr, filters};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
@@ -17,37 +17,27 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
|||||||
|
|
||||||
impl Global for GlobalRepoListStore {}
|
impl Global for GlobalRepoListStore {}
|
||||||
|
|
||||||
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct RepoActivityCounts {
|
pub struct RepoActivityCounts {
|
||||||
/// Root `30611` issue events addressed to the repository.
|
|
||||||
pub issues: u32,
|
pub issues: u32,
|
||||||
/// Root `3063` pull request events addressed to the repository.
|
// PR updates are not new PRs and do not count.
|
||||||
///
|
|
||||||
/// PR updates are not new PRs and do not count.
|
|
||||||
pub pull_requests: u32,
|
pub pull_requests: u32,
|
||||||
/// `1617` patch events addressed to the repository.
|
|
||||||
pub commits: u32,
|
pub commits: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepoActivityCounts {
|
impl RepoActivityCounts {
|
||||||
/// Total issues, pull requests and commits, the popularity ranking key.
|
// The popularity ranking key.
|
||||||
pub fn score(self) -> u32 {
|
pub fn score(self) -> u32 {
|
||||||
self.issues + self.pull_requests + self.commits
|
self.issues + self.pull_requests + self.commits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store listing the discovered repository announcements, newest first.
|
|
||||||
pub struct RepoListStore {
|
pub struct RepoListStore {
|
||||||
/// Shared so views can clone the list per frame without a deep copy.
|
// Shared so views can clone the list per frame without a deep copy.
|
||||||
pub announcements: Arc<Vec<Announcement>>,
|
pub announcements: Arc<Vec<Announcement>>,
|
||||||
/// Latest known activity timestamp per repository.
|
|
||||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||||
/// Issues, pull requests and commits per repository.
|
// For the Popular ranking of the explore list.
|
||||||
///
|
|
||||||
/// Used for the Popular ranking of the explore list.
|
|
||||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||||
/// Own repositories whose state events were fetched from their announced relays.
|
|
||||||
state_synced_repos: HashSet<RepoAddr>,
|
state_synced_repos: HashSet<RepoAddr>,
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
@@ -100,7 +90,6 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The announcements of `user`, newest first.
|
|
||||||
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
|
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
|
||||||
self.announcements
|
self.announcements
|
||||||
.iter()
|
.iter()
|
||||||
@@ -115,17 +104,16 @@ impl RepoListStore {
|
|||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.sync_bootstraps(
|
backend.sync_bootstraps(
|
||||||
vec![
|
vec![
|
||||||
filters::all_announcements(),
|
Filters::all_announcements(),
|
||||||
filters::all_states(),
|
Filters::all_states(),
|
||||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||||
filters::deletions(),
|
Filters::deletions(),
|
||||||
],
|
],
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the state events of the user's own repositories.
|
|
||||||
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
|
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let Some(me) = backend.read(cx).current_user() else {
|
let Some(me) = backend.read(cx).current_user() else {
|
||||||
@@ -144,15 +132,13 @@ impl RepoListStore {
|
|||||||
self.state_synced_repos.insert(addr.clone());
|
self.state_synced_repos.insert(addr.clone());
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx);
|
backend.connect_repo_relays(relays, vec![addr.state_filter()], cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-query the local database.
|
// Runs immediately: the backend pump already batches the relay events
|
||||||
///
|
// that trigger a refresh, so no per-store debounce is needed.
|
||||||
/// Runs immediately. The backend pump already batches the relay events that
|
|
||||||
/// trigger a refresh, so no per-store debounce is needed.
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.refresh.request() != RefreshRequest::Schedule {
|
if self.refresh.request() != RefreshRequest::Schedule {
|
||||||
return;
|
return;
|
||||||
@@ -168,14 +154,14 @@ impl RepoListStore {
|
|||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = filters::all_announcements();
|
let filter = Filters::all_announcements();
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
let deletion_events = client.database().query(Filters::deletions()).await?;
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
// Dedup and sort off the main thread.
|
// Dedup and sort off the main thread; only the final list crosses
|
||||||
// Only the final list crosses back into the entity.
|
// back into the entity.
|
||||||
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
@@ -213,14 +199,13 @@ impl RepoListStore {
|
|||||||
let Some(id) = event.tags.identifier() else {
|
let Some(id) = event.tags.identifier() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let addr = repo_addr(event.pubkey, id);
|
let addr = RepoAddr::new(event.pubkey, id);
|
||||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
*entry = (*entry).max(event.created_at);
|
*entry = (*entry).max(event.created_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bound the activity query to a recent window.
|
|
||||||
// Older repos fall back to their announcement or state timestamps.
|
// Older repos fall back to their announcement or state timestamps.
|
||||||
let activity_filter = Filter::new()
|
let activity_filter = Filter::new()
|
||||||
.kinds(filters::ACTIVITY_KINDS)
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
@@ -230,10 +215,11 @@ impl RepoListStore {
|
|||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for addr in event.tags.coordinates() {
|
for coordinate in event.tags.coordinates() {
|
||||||
if addr.kind != Kind::GitRepoAnnouncement {
|
if coordinate.kind != Kind::GitRepoAnnouncement {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let addr = RepoAddr::from(coordinate.clone());
|
||||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -241,8 +227,8 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popularity counts per repository, issues, pull requests and patches.
|
// Unbounded, unlike the windowed activity query above, so totals
|
||||||
// Unbounded, unlike the windowed activity query above, so totals are exact.
|
// are exact.
|
||||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||||
let count_filter =
|
let count_filter =
|
||||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||||
@@ -251,12 +237,13 @@ impl RepoListStore {
|
|||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for addr in event.tags.coordinates() {
|
for coordinate in event.tags.coordinates() {
|
||||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
if coordinate.kind != Kind::GitRepoAnnouncement
|
||||||
|
|| !last_activity.contains_key(&RepoAddr::from(coordinate.clone()))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let entry = counts.entry(addr).or_default();
|
let entry = counts.entry(RepoAddr::from(coordinate)).or_default();
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GitIssue => entry.issues += 1,
|
Kind::GitIssue => entry.issues += 1,
|
||||||
Kind::GitPullRequest => entry.pull_requests += 1,
|
Kind::GitPullRequest => entry.pull_requests += 1,
|
||||||
@@ -272,7 +259,6 @@ impl RepoListStore {
|
|||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let (announcements, last_activity, counts) = match work.await {
|
let (announcements, last_activity, counts) = match work.await {
|
||||||
Ok(results) => results,
|
Ok(results) => results,
|
||||||
// Database errors are transient, keep the last list.
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return this.update(cx, |this, _cx| {
|
return this.update(cx, |this, _cx| {
|
||||||
this.refresh.abort();
|
this.refresh.abort();
|
||||||
@@ -290,8 +276,6 @@ impl RepoListStore {
|
|||||||
this.refresh.finish()
|
this.refresh.finish()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Requests that arrived while the refresh was running.
|
|
||||||
// They are coalesced into one follow-up refresh.
|
|
||||||
if again {
|
if again {
|
||||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ mod title_bar;
|
|||||||
mod tree_row;
|
mod tree_row;
|
||||||
|
|
||||||
pub mod copy_row;
|
pub mod copy_row;
|
||||||
pub mod util;
|
|
||||||
|
|
||||||
pub use avatar::Avatar;
|
pub use avatar::Avatar;
|
||||||
pub use copy_row::{copy_row, menu_copy_row};
|
pub use copy_row::{copy_row, menu_copy_row};
|
||||||
@@ -25,4 +24,3 @@ pub use setting::{SelectOption, setting_block, setting_row};
|
|||||||
pub use status_badge::status_badge;
|
pub use status_badge::status_badge;
|
||||||
pub use title_bar::title_bar_drag_handlers;
|
pub use title_bar::title_bar_drag_handlers;
|
||||||
pub use tree_row::tree_row;
|
pub use tree_row::tree_row;
|
||||||
pub use util::middle_truncate;
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
/// `[head chars]...[tail chars]` middle truncation.
|
|
||||||
///
|
|
||||||
/// Values too short for the ellipsis to save space are left alone.
|
|
||||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
|
||||||
let len = value.chars().count();
|
|
||||||
if len <= head + tail + 3 {
|
|
||||||
return value.to_string();
|
|
||||||
}
|
|
||||||
let head: String = value.chars().take(head).collect();
|
|
||||||
let tail: String = value.chars().skip(len - tail).collect();
|
|
||||||
format!("{head}...{tail}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn middle_truncates_long_values_only() {
|
|
||||||
assert_eq!(
|
|
||||||
middle_truncate(
|
|
||||||
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
|
|
||||||
10,
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
"a008def157...ad57a3564d"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
middle_truncate(
|
|
||||||
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit",
|
|
||||||
10,
|
|
||||||
10
|
|
||||||
),
|
|
||||||
"30617:a008...3564d:ngit"
|
|
||||||
);
|
|
||||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
mod pubkey;
|
mod pubkey;
|
||||||
|
mod repo_url;
|
||||||
|
mod text;
|
||||||
mod time;
|
mod time;
|
||||||
|
|
||||||
pub use pubkey::shorten_pubkey;
|
pub use pubkey::shorten_pubkey;
|
||||||
pub use time::{relative_time, relative_time_secs};
|
pub use repo_url::same_repo_url;
|
||||||
|
pub use text::{flatten_whitespace, middle_truncate};
|
||||||
|
pub use time::{latest, relative_time, relative_time_secs, sort_newest_first, sort_oldest_first};
|
||||||
|
|||||||
@@ -1,49 +1,14 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
use crate::text::middle_truncate;
|
||||||
|
|
||||||
|
const HEAD_CHARS: usize = 9;
|
||||||
|
const TAIL_CHARS: usize = 4;
|
||||||
|
|
||||||
pub fn shorten_pubkey(public_key: PublicKey) -> String {
|
pub fn shorten_pubkey(public_key: PublicKey) -> String {
|
||||||
let encoded = public_key
|
let encoded = public_key
|
||||||
.to_bech32()
|
.to_bech32()
|
||||||
.unwrap_or_else(|_| public_key.to_hex());
|
.unwrap_or_else(|_| public_key.to_hex());
|
||||||
|
|
||||||
truncate_middle(&encoded)
|
middle_truncate(&encoded, HEAD_CHARS, TAIL_CHARS)
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_middle(value: &str) -> String {
|
|
||||||
const HEAD_CHARS: usize = 9;
|
|
||||||
const TAIL_CHARS: usize = 4;
|
|
||||||
|
|
||||||
let length = value.chars().count();
|
|
||||||
if length <= HEAD_CHARS + TAIL_CHARS + 3 {
|
|
||||||
return value.to_owned();
|
|
||||||
}
|
|
||||||
|
|
||||||
let head: String = value.chars().take(HEAD_CHARS).collect();
|
|
||||||
let tail: String = value.chars().skip(length - TAIL_CHARS).collect();
|
|
||||||
|
|
||||||
format!("{head}...{tail}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const PUBLIC_KEY_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shortens_a_valid_pubkey() {
|
|
||||||
let public_key = PublicKey::from_hex(PUBLIC_KEY_HEX).expect("valid pubkey");
|
|
||||||
let npub = public_key.to_bech32().expect("valid pubkey encodes");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
shorten_pubkey(public_key),
|
|
||||||
format!("{}...{}", &npub[..9], &npub[npub.len() - 4..])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leaves_short_values_intact() {
|
|
||||||
assert_eq!(truncate_middle("npub1short"), "npub1short");
|
|
||||||
assert_eq!(truncate_middle("thirteenchars"), "thirteenchars");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Compare git URLs ignoring scheme, trailing `.git` and path slashes, so a
|
||||||
|
/// grasp announce URL matches its https origin. Unparseable values compare literally.
|
||||||
|
pub fn same_repo_url(a: &str, b: &str) -> bool {
|
||||||
|
match (url_identity(a), url_identity(b)) {
|
||||||
|
(Some(a), Some(b)) => a == b,
|
||||||
|
_ => a == b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||||
|
let parsed = Url::parse(url).ok()?;
|
||||||
|
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||||
|
let mut path = parsed.path().trim_matches('/').to_owned();
|
||||||
|
if let Some(stripped) = path.strip_suffix(".git") {
|
||||||
|
path = stripped.to_owned();
|
||||||
|
}
|
||||||
|
Some((host, parsed.port(), path))
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||||
|
let len = value.chars().count();
|
||||||
|
if len <= head + tail + 3 {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
let head: String = value.chars().take(head).collect();
|
||||||
|
let tail: String = value.chars().skip(len - tail).collect();
|
||||||
|
format!("{head}...{tail}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flatten_whitespace(text: &str) -> String {
|
||||||
|
const MAX_CHARS: usize = 200;
|
||||||
|
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if flat.chars().count() <= MAX_CHARS {
|
||||||
|
flat
|
||||||
|
} else {
|
||||||
|
let mut clipped: String = flat.chars().take(MAX_CHARS).collect();
|
||||||
|
clipped.push('…');
|
||||||
|
clipped
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-20
@@ -1,6 +1,24 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Format a timestamp as a short relative time, e.g. `3h ago`.
|
pub fn sort_newest_first(events: &mut [Event]) {
|
||||||
|
events.sort_by(|a, b| {
|
||||||
|
b.created_at
|
||||||
|
.cmp(&a.created_at)
|
||||||
|
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_oldest_first(events: &mut [Event]) {
|
||||||
|
events.sort_by_key(|e| e.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn latest<I>(events: I) -> Option<Event>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = Event>,
|
||||||
|
{
|
||||||
|
events.into_iter().max_by_key(|e| e.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn relative_time(timestamp: Timestamp) -> String {
|
pub fn relative_time(timestamp: Timestamp) -> String {
|
||||||
let now = Timestamp::now().as_secs();
|
let now = Timestamp::now().as_secs();
|
||||||
let secs = now.saturating_sub(timestamp.as_secs());
|
let secs = now.saturating_sub(timestamp.as_secs());
|
||||||
@@ -20,25 +38,6 @@ pub fn relative_time(timestamp: Timestamp) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`.
|
|
||||||
pub fn relative_time_secs(secs: i64) -> String {
|
pub fn relative_time_secs(secs: i64) -> String {
|
||||||
relative_time(Timestamp::from_secs(secs.max(0) as u64))
|
relative_time(Timestamp::from_secs(secs.max(0) as u64))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn formats_relative_time() {
|
|
||||||
let now = Timestamp::now();
|
|
||||||
|
|
||||||
assert_eq!(relative_time(now), "just now");
|
|
||||||
assert_eq!(relative_time(now - 300), "5m ago");
|
|
||||||
assert_eq!(relative_time(now - 7_200), "2h ago");
|
|
||||||
assert_eq!(relative_time(now - 3 * 86_400), "3d ago");
|
|
||||||
assert_eq!(relative_time(now - 60 * 86_400), "2mo ago");
|
|
||||||
assert_eq!(relative_time(now - 800 * 86_400), "2y ago");
|
|
||||||
assert_eq!(relative_time(now + 600), "just now");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
|||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
use signed_git::{
|
||||||
|
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
||||||
|
};
|
||||||
use signed_state::RepoStore;
|
use signed_state::RepoStore;
|
||||||
use signed_ui::{placeholder, tree_row};
|
use signed_ui::{placeholder, tree_row};
|
||||||
use utils::relative_time_secs;
|
use utils::relative_time_secs;
|
||||||
@@ -355,14 +357,14 @@ impl CommitDiffView {
|
|||||||
.background_spawn({
|
.background_spawn({
|
||||||
let worktree = worktree.clone();
|
let worktree = worktree.clone();
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
async move { signed_git::worktree_commit(&worktree, &id) }
|
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let diff = cx
|
let diff = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let worktree = worktree.clone();
|
let worktree = worktree.clone();
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
async move { signed_git::worktree_commit_diff(&worktree, &id) }
|
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt,
|
|||||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||||
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore,
|
||||||
};
|
};
|
||||||
use signed_ui::{Avatar, CountBadge};
|
use signed_ui::{Avatar, CountBadge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -199,7 +199,7 @@ impl InboxView {
|
|||||||
|
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await });
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let (threads, unread_count) = match work.await {
|
let (threads, unread_count) = match work.await {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use gpui_component::input::TextareaState;
|
|||||||
use gpui_component::scroll::ScrollableElement;
|
use gpui_component::scroll::ScrollableElement;
|
||||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::activity_subject;
|
use signed_core::GitEvent;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{Avatar, placeholder, status_badge};
|
use signed_ui::{Avatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -103,7 +103,7 @@ impl Render for IssueDetailView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
(
|
(
|
||||||
activity_subject(issue),
|
issue.activity_subject(),
|
||||||
profile.name(),
|
profile.name(),
|
||||||
profile.picture(),
|
profile.picture(),
|
||||||
store.status_of(issue),
|
store.status_of(issue),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{GitEvent, RepoStatus};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -146,7 +146,7 @@ impl IssuesView {
|
|||||||
|
|
||||||
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let issue = &self.store.read(cx).issues[issue_ix];
|
let issue = &self.store.read(cx).issues[issue_ix];
|
||||||
let title = activity_subject(issue);
|
let title = issue.activity_subject();
|
||||||
let id_hex = issue.id.to_hex();
|
let id_hex = issue.id.to_hex();
|
||||||
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
||||||
let author = profile.name();
|
let author = profile.name();
|
||||||
|
|||||||
@@ -21,12 +21,9 @@ use gpui_component::{
|
|||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{Event, EventId, Kind, Url};
|
use nostr::prelude::{Event, EventId, Kind, Url};
|
||||||
use signed_core::{
|
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
||||||
RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
|
use signed_git::{FileCommit, PatchParser};
|
||||||
merge_base_of, pull_request_patch,
|
use signed_state::{Backend, Mirrors, ProfileStore, RepoStore};
|
||||||
};
|
|
||||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
|
||||||
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
|
|
||||||
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||||
use utils::{relative_time, relative_time_secs};
|
use utils::{relative_time, relative_time_secs};
|
||||||
|
|
||||||
@@ -145,23 +142,24 @@ impl PullRequestDetailView {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||||
.map(|root| {
|
.map(|root| {
|
||||||
let update = latest_update(store.pull_requests.iter(), root);
|
let update = PullRequest::latest_update(store.pull_requests.iter(), root);
|
||||||
|
|
||||||
let tip = update
|
let tip = update
|
||||||
.and_then(current_commit_of)
|
.and_then(GitEvent::current_commit)
|
||||||
.or_else(|| current_commit_of(root));
|
.or_else(|| root.current_commit());
|
||||||
|
|
||||||
let base = update
|
let base = update
|
||||||
.and_then(merge_base_of)
|
.and_then(GitEvent::merge_base)
|
||||||
.or_else(|| merge_base_of(root));
|
.or_else(|| root.merge_base());
|
||||||
|
|
||||||
let clone_urls = clone_urls_of(root)
|
let clone_urls = root
|
||||||
|
.clone_urls()
|
||||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
PrBinding {
|
PrBinding {
|
||||||
description: root.content.clone(),
|
description: root.content.clone(),
|
||||||
patch: pull_request_patch(root, store.patches.iter()),
|
patch: PullRequest::new(root).patch(store.patches.iter()),
|
||||||
tip,
|
tip,
|
||||||
base,
|
base,
|
||||||
clone_urls,
|
clone_urls,
|
||||||
@@ -235,14 +233,14 @@ impl PullRequestDetailView {
|
|||||||
let nostr_diff = cx
|
let nostr_diff = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
async move { patch_diffs(&patch) }
|
async move { PatchParser::patch_diffs(&patch) }
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let nostr_commits = cx
|
let nostr_commits = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
async move { patch_commits(&patch) }
|
async move { PatchParser::patch_commits(&patch) }
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -263,9 +261,10 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
Some(
|
Some(
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
|
let repo = Mirrors::ensure(&addr, &clone_urls)?;
|
||||||
|
let gix_repo = repo.inner();
|
||||||
|
|
||||||
let workdir = repo
|
let workdir = gix_repo
|
||||||
.workdir()
|
.workdir()
|
||||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
@@ -277,17 +276,16 @@ impl PullRequestDetailView {
|
|||||||
Some(base) => base,
|
Some(base) => base,
|
||||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||||
None => {
|
None => {
|
||||||
let head = repo
|
let head = gix_repo
|
||||||
.head_id()
|
.head_id()
|
||||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?;
|
||||||
repo.merge_base(tip_id, head)?.to_string()
|
gix_repo.merge_base(tip_id, head)?.to_string()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
let diff = repo.range_diff(&base, &tip)?;
|
||||||
let commits =
|
let commits = repo.commit_range(&base, &tip)?;
|
||||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||||
})
|
})
|
||||||
@@ -620,9 +618,9 @@ impl PullRequestDetailView {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
activity_subject(root),
|
root.activity_subject(),
|
||||||
store.status_of(root),
|
store.status_of(root),
|
||||||
branch_name_of(root),
|
root.branch_name(),
|
||||||
root.pubkey,
|
root.pubkey,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{EventId, Kind};
|
use nostr::prelude::{EventId, Kind};
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{GitEvent, RepoStatus};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -159,7 +159,7 @@ impl PullRequestsView {
|
|||||||
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||||
let pr_id = pr.id;
|
let pr_id = pr.id;
|
||||||
let title = activity_subject(pr);
|
let title = pr.activity_subject();
|
||||||
let id_hex = pr.id.to_hex();
|
let id_hex = pr.id.to_hex();
|
||||||
|
|
||||||
let age = relative_time(pr.created_at);
|
let age = relative_time(pr.created_at);
|
||||||
|
|||||||
@@ -21,15 +21,11 @@ use gpui_component::{
|
|||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use signed_core::{Announcement, RepoAddr, fork_candidates};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_git::{
|
use signed_git::{GitCache, Repo};
|
||||||
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
|
use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore};
|
||||||
worktree_commit_range_commits, worktree_commit_range_diff,
|
|
||||||
};
|
|
||||||
use signed_state::{
|
|
||||||
Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path,
|
|
||||||
};
|
|
||||||
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
||||||
|
use utils::middle_truncate;
|
||||||
|
|
||||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
||||||
use crate::views::{repo_tab_avatar, tab_title};
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
@@ -104,17 +100,8 @@ fn shorten_owner(owner: &PublicKey) -> String {
|
|||||||
hex.chars().take(10).collect()
|
hex.chars().take(10).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Truncate a label for the fixed-width controls of the compare bar.
|
|
||||||
fn truncate_label(label: &str) -> SharedString {
|
fn truncate_label(label: &str) -> SharedString {
|
||||||
const MAX: usize = 18;
|
SharedString::from(middle_truncate(label, 18, 0))
|
||||||
let mut chars = label.chars();
|
|
||||||
let (prefix, rest) = (chars.by_ref().take(MAX).collect::<String>(), chars.next());
|
|
||||||
let label = if rest.is_some() {
|
|
||||||
format!("{}…", &prefix[..prefix.len().saturating_sub(1)])
|
|
||||||
} else {
|
|
||||||
prefix
|
|
||||||
};
|
|
||||||
SharedString::from(label)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checkout_source_item(
|
fn checkout_source_item(
|
||||||
@@ -407,10 +394,12 @@ impl NewPullRequestView {
|
|||||||
.background_spawn({
|
.background_spawn({
|
||||||
let path = path.clone();
|
let path = path.clone();
|
||||||
async move {
|
async move {
|
||||||
let repo = gix::open(Path::new(&path)).ok()?;
|
let repo = Repo::try_open(Path::new(&path));
|
||||||
let branches =
|
let branches = repo
|
||||||
signed_git::worktree_branches(Path::new(&path)).unwrap_or_default();
|
.as_ref()
|
||||||
let current = signed_git::current_branch(&repo).ok().flatten();
|
.and_then(|repo| repo.branches().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let current = repo.as_ref().and_then(|repo| repo.current_branch());
|
||||||
Some((branches, current))
|
Some((branches, current))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -520,7 +509,7 @@ impl NewPullRequestView {
|
|||||||
};
|
};
|
||||||
let user = Backend::global(cx).read(cx).current_user();
|
let user = Backend::global(cx).read(cx).current_user();
|
||||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||||
fork_candidates(&announcements, &base, euc.as_deref(), user)
|
Announcement::forks_in(&announcements, &base, euc.as_deref(), user)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -540,8 +529,8 @@ impl NewPullRequestView {
|
|||||||
let Some((base, _euc)) = self.base_repo(cx) else {
|
let Some((base, _euc)) = self.base_repo(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mirror_path = repo_mirror_path(&base);
|
let mirror_path = Mirrors::path(&base);
|
||||||
let namespace = fork_namespace(&announcement);
|
let namespace = GitCache::fork_namespace(&announcement);
|
||||||
let clone_urls = announcement.clone.clone();
|
let clone_urls = announcement.clone.clone();
|
||||||
|
|
||||||
let base_clone_urls: Vec<Url> = self
|
let base_clone_urls: Vec<Url> = self
|
||||||
@@ -575,14 +564,14 @@ impl NewPullRequestView {
|
|||||||
let clone_urls = clone_urls.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
let mirror_path = mirror_path.clone();
|
let mirror_path = mirror_path.clone();
|
||||||
async move {
|
async move {
|
||||||
ensure_repo_mirror(&base, &base_clone_urls)?;
|
Mirrors::ensure(&base, &base_clone_urls)?;
|
||||||
|
let repo = Repo::open(&mirror_path)?;
|
||||||
|
|
||||||
// Prune stale imports of any fork. Then import this fork's heads under its namespace.
|
// Prune stale imports of any fork. Then import this fork's heads under its namespace.
|
||||||
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
|
repo.delete_refs_with_prefix("refs/fork")?;
|
||||||
|
|
||||||
// Fetch the fork's refs and import them under the fork's namespace.
|
// Fetch the fork's refs and import them under the fork's namespace.
|
||||||
fetch_repo_refs(
|
repo.fetch_refs(
|
||||||
&mirror_path,
|
|
||||||
&clone_urls,
|
&clone_urls,
|
||||||
&format!("+refs/heads/*:refs/fork/{namespace}/*"),
|
&format!("+refs/heads/*:refs/fork/{namespace}/*"),
|
||||||
)?;
|
)?;
|
||||||
@@ -602,12 +591,12 @@ impl NewPullRequestView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let base_branches = strip(
|
let base_branches = strip(
|
||||||
refs_with_prefix(&mirror_path, "refs/remotes/origin")?,
|
repo.refs_with_prefix("refs/remotes/origin")?,
|
||||||
"refs/remotes/origin",
|
"refs/remotes/origin",
|
||||||
);
|
);
|
||||||
|
|
||||||
let compare_branches = strip(
|
let compare_branches = strip(
|
||||||
refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?,
|
repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?,
|
||||||
&format!("refs/fork/{namespace}"),
|
&format!("refs/fork/{namespace}"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -781,24 +770,17 @@ impl NewPullRequestView {
|
|||||||
let base_name = base_name.clone();
|
let base_name = base_name.clone();
|
||||||
let compare_name = compare_name.clone();
|
let compare_name = compare_name.clone();
|
||||||
async move {
|
async move {
|
||||||
let merge_base = merge_base(Path::new(&repo_path), &base, &compare)?
|
let repo = Repo::open(Path::new(&repo_path))?;
|
||||||
.ok_or_else(|| {
|
let merge_base =
|
||||||
|
repo.merge_base(&base, &compare)?.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"{base_name} and {compare_name} share no common ancestor"
|
"{base_name} and {compare_name} share no common ancestor"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let commits = worktree_commit_range_commits(
|
let commits = repo.commit_range(&merge_base, &compare)?;
|
||||||
Path::new(&repo_path),
|
|
||||||
&merge_base,
|
|
||||||
&compare,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let diff = worktree_commit_range_diff(
|
let diff = repo.range_diff(&merge_base, &compare)?;
|
||||||
Path::new(&repo_path),
|
|
||||||
&merge_base,
|
|
||||||
&compare,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>((merge_base, commits, diff))
|
Ok::<_, anyhow::Error>((merge_base, commits, diff))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
|||||||
use nostr::prelude::PublicKey;
|
use nostr::prelude::PublicKey;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::ProfileStore;
|
use signed_state::ProfileStore;
|
||||||
use signed_ui::{Avatar, middle_truncate};
|
use signed_ui::Avatar;
|
||||||
|
use utils::middle_truncate;
|
||||||
|
|
||||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use gpui_component::spinner::Spinner;
|
|||||||
use gpui_component::text::{TextView, TextViewState};
|
use gpui_component::text::{TextView, TextViewState};
|
||||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use signed_git::{FileCommit, WorktreeSnapshot};
|
use signed_git::{FileCommit, Repo, WorktreeSnapshot};
|
||||||
use signed_ui::{placeholder, tree_row};
|
use signed_ui::{placeholder, tree_row};
|
||||||
|
|
||||||
use crate::views::tree::{TreeItemSeed, tree_items};
|
use crate::views::tree::{TreeItemSeed, tree_items};
|
||||||
@@ -611,9 +611,9 @@ impl RepoFilesView {
|
|||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(
|
.background_spawn(async move {
|
||||||
async move { signed_git::worktree_last_commits(&worktree, &rels) },
|
Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels))
|
||||||
)
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div,
|
|||||||
use gpui_component::scroll::Scrollbar;
|
use gpui_component::scroll::Scrollbar;
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
|
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
|
||||||
use signed_git::CommitList;
|
use signed_git::{CommitList, Repo};
|
||||||
use signed_state::RepoStore;
|
use signed_state::RepoStore;
|
||||||
use signed_ui::placeholder;
|
use signed_ui::placeholder;
|
||||||
|
|
||||||
@@ -69,7 +69,9 @@ impl RepoHistoryView {
|
|||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
|
.background_spawn(async move {
|
||||||
|
Repo::open_cached(&worktree).and_then(|repo| repo.all_commits())
|
||||||
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use std::rc::Rc;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||||
use gix::Repository;
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
@@ -24,16 +23,15 @@ use gpui_component::{
|
|||||||
use nostr::nips::nip19::Nip19Coordinate;
|
use nostr::nips::nip19::Nip19Coordinate;
|
||||||
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
||||||
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
||||||
use signed_git::FileCommit;
|
use signed_git::{FileCommit, GitCache, Repo};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind,
|
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind,
|
||||||
ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror,
|
ProfileStore, RepoListStore, RepoStore,
|
||||||
pr_proposes_checkout,
|
|
||||||
};
|
};
|
||||||
use signed_ui::{
|
use signed_ui::{
|
||||||
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, middle_truncate,
|
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
|
||||||
ref_selector_trigger,
|
|
||||||
};
|
};
|
||||||
|
use utils::middle_truncate;
|
||||||
|
|
||||||
mod about;
|
mod about;
|
||||||
mod actions;
|
mod actions;
|
||||||
@@ -326,7 +324,7 @@ impl RepoDetailView {
|
|||||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
let data = cx
|
let data = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
let repo = gix::open(&local_path)?;
|
let repo = Repo::open(&local_path)?;
|
||||||
load_repo_data(&repo)
|
load_repo_data(&repo)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@@ -360,7 +358,7 @@ impl RepoDetailView {
|
|||||||
let disk = {
|
let disk = {
|
||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
match open_repo_mirror(&addr)? {
|
match Mirrors::open(&addr)? {
|
||||||
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
|
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
@@ -377,7 +375,7 @@ impl RepoDetailView {
|
|||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
let clone_urls = clone_urls.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
|
let repo = Mirrors::ensure(&addr, &clone_urls)?;
|
||||||
load_repo_data(&repo)
|
load_repo_data(&repo)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -404,42 +402,27 @@ impl RepoDetailView {
|
|||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let Some(repo) = open_repo_mirror(&addr)? else {
|
let Some(repo) = Mirrors::open(&addr)? else {
|
||||||
return Ok::<_, Error>(None);
|
return Ok::<_, Error>(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
|
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
|
||||||
signed_git::fetch_all(&repo).ok();
|
repo.fetch().ok();
|
||||||
|
|
||||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
let moved = repo.fast_forward_branches().unwrap_or(false);
|
||||||
|
|
||||||
let moved = match &worktree {
|
Ok::<_, Error>(Some((moved, repo.snapshot()?)))
|
||||||
Some(worktree) => {
|
|
||||||
signed_git::fast_forward_branches(worktree).unwrap_or(false)
|
|
||||||
}
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let (branches, tags) = match &worktree {
|
|
||||||
Some(_) => (
|
|
||||||
signed_git::repo_branches(&repo).unwrap_or_default(),
|
|
||||||
signed_git::repo_tags(&repo).unwrap_or_default(),
|
|
||||||
),
|
|
||||||
None => (Vec::new(), Vec::new()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
|
|
||||||
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
|
|
||||||
|
|
||||||
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
|
if let Ok(Some((moved, snapshot))) = refresh {
|
||||||
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
|
let branches: Vec<SharedString> =
|
||||||
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
|
snapshot.branches.iter().map(Into::into).collect();
|
||||||
|
let tags: Vec<SharedString> = snapshot.tags.iter().map(Into::into).collect();
|
||||||
|
let current_branch = snapshot.current_branch.clone();
|
||||||
|
let head_commit = snapshot.head_commit.clone();
|
||||||
|
|
||||||
let branches_changed = this.refs.set_branches(
|
let branches_changed = this.refs.set_branches(
|
||||||
branches,
|
branches,
|
||||||
@@ -531,8 +514,8 @@ impl RepoDetailView {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|name| name.to_string())
|
.map(|name| name.to_string())
|
||||||
.filter(|name| !name.trim().is_empty())
|
.filter(|name| !name.trim().is_empty())
|
||||||
.unwrap_or_else(|| addr.identifier.clone());
|
.unwrap_or_else(|| addr.identifier().to_owned());
|
||||||
let name = signed_git::sanitize_path_component(&name);
|
let name = GitCache::sanitize_path_component(&name);
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
"repository".to_owned()
|
"repository".to_owned()
|
||||||
@@ -617,13 +600,12 @@ impl RepoDetailView {
|
|||||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
|
let repo = Repo::open(&worktree);
|
||||||
match kind {
|
match kind {
|
||||||
RefKind::Branch => {
|
RefKind::Branch => {
|
||||||
signed_git::worktree_checkout_branch(&worktree, &checkout_name)
|
repo.and_then(|repo| repo.checkout_branch(&checkout_name))
|
||||||
}
|
|
||||||
RefKind::Tag => {
|
|
||||||
signed_git::worktree_checkout_tag(&worktree, &checkout_name)
|
|
||||||
}
|
}
|
||||||
|
RefKind::Tag => repo.and_then(|repo| repo.checkout_tag(&checkout_name)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@@ -665,7 +647,7 @@ impl RepoDetailView {
|
|||||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
let snapshot = signed_git::worktree_snapshot(&worktree)?;
|
let snapshot = Repo::open(&worktree)?.snapshot()?;
|
||||||
// Build the tree off the main thread, like [`Self::load_repo`].
|
// Build the tree off the main thread, like [`Self::load_repo`].
|
||||||
let tree = build_tree_items(&snapshot.entries);
|
let tree = build_tree_items(&snapshot.entries);
|
||||||
let paths = sorted_worktree_paths(&snapshot.entries);
|
let paths = sorted_worktree_paths(&snapshot.entries);
|
||||||
@@ -716,7 +698,7 @@ impl RepoDetailView {
|
|||||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
let snapshot = signed_git::worktree_snapshot(&worktree)?;
|
let snapshot = Repo::open(&worktree)?.snapshot()?;
|
||||||
let tree = build_tree_items(&snapshot.entries);
|
let tree = build_tree_items(&snapshot.entries);
|
||||||
let paths = sorted_worktree_paths(&snapshot.entries);
|
let paths = sorted_worktree_paths(&snapshot.entries);
|
||||||
Ok::<_, Error>((snapshot, tree, paths))
|
Ok::<_, Error>((snapshot, tree, paths))
|
||||||
@@ -1523,8 +1505,12 @@ impl RepoDetailView {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for pr in &store.pull_requests {
|
for pr in &store.pull_requests {
|
||||||
if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status)
|
if CheckoutsStore::pr_proposes_checkout(
|
||||||
{
|
pr,
|
||||||
|
store.status_of(pr) == RepoStatus::Open,
|
||||||
|
user,
|
||||||
|
&status,
|
||||||
|
) {
|
||||||
continue 'status;
|
continue 'status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1857,7 +1843,7 @@ fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Op
|
|||||||
.map(SharedString::from)
|
.map(SharedString::from)
|
||||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
|
||||||
(SharedString::from(format!("Forked from {name}")), true)
|
(SharedString::from(format!("Forked from {name}")), true)
|
||||||
}
|
}
|
||||||
None => (SharedString::from(upstream.display().as_str()), false),
|
None => (SharedString::from(upstream.display().as_str()), false),
|
||||||
@@ -1900,7 +1886,7 @@ impl ShareTargets {
|
|||||||
fn from_announcement(announcement: &Announcement) -> Self {
|
fn from_announcement(announcement: &Announcement) -> Self {
|
||||||
let addr = announcement.addr();
|
let addr = announcement.addr();
|
||||||
let coordinate = addr.to_string();
|
let coordinate = addr.to_string();
|
||||||
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
|
let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned())
|
||||||
.to_bech32()
|
.to_bech32()
|
||||||
.expect("a complete coordinate always encodes to naddr");
|
.expect("a complete coordinate always encodes to naddr");
|
||||||
|
|
||||||
@@ -1952,38 +1938,21 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
|||||||
format!("{}...{}", &url[..end], &url[url.len() - tail..])
|
format!("{}...{}", &url[..end], &url[url.len() - tail..])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
|
fn load_repo_data(repo: &Repo) -> Result<RepoData, Error> {
|
||||||
let entries = signed_git::worktree_entries(repo)?;
|
let snapshot = repo.snapshot()?;
|
||||||
let tree = build_tree_items(&entries);
|
|
||||||
let readme_path = signed_git::find_readme(repo)?;
|
|
||||||
|
|
||||||
let readme = match &readme_path {
|
|
||||||
Some(path) => signed_git::worktree_read(repo, path)?,
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
let worktree = repo.workdir().map(Path::to_path_buf);
|
||||||
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
|
let tree = build_tree_items(&snapshot.entries);
|
||||||
|
|
||||||
let (branches, tags, current_branch) = match &worktree {
|
|
||||||
Some(_) => (
|
|
||||||
signed_git::repo_branches(repo).unwrap_or_default(),
|
|
||||||
signed_git::repo_tags(repo).unwrap_or_default(),
|
|
||||||
signed_git::current_branch(repo).unwrap_or(None),
|
|
||||||
),
|
|
||||||
None => (Vec::new(), Vec::new(), None),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(RepoData {
|
Ok(RepoData {
|
||||||
tree,
|
tree,
|
||||||
entries,
|
entries: snapshot.entries,
|
||||||
readme_path,
|
readme_path: snapshot.readme_path,
|
||||||
readme,
|
readme: snapshot.readme,
|
||||||
worktree,
|
worktree,
|
||||||
branches,
|
branches: snapshot.branches,
|
||||||
tags,
|
tags: snapshot.tags,
|
||||||
current_branch,
|
current_branch: snapshot.current_branch,
|
||||||
head_commit,
|
head_commit: snapshot.head_commit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ impl RepoListView {
|
|||||||
.map(SharedString::from)
|
.map(SharedString::from)
|
||||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
|
||||||
Some(SharedString::from(format!("forked from {name}")))
|
Some(SharedString::from(format!("forked from {name}")))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use nostr::prelude::RelayUrl;
|
|||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
||||||
ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos,
|
ProfileStore, RepoListStore, ResolvedLocalRepo,
|
||||||
};
|
};
|
||||||
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ impl SidebarPanel {
|
|||||||
|
|
||||||
let local = LocalReposStore::global(cx).read(cx);
|
let local = LocalReposStore::global(cx).read(cx);
|
||||||
let local_repos =
|
let local_repos =
|
||||||
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements);
|
||||||
|
|
||||||
(announcements, local_repos, local.scanning)
|
(announcements, local_repos, local.scanning)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
|
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
||||||
use gpui_component::{Root, StyledExt, Theme};
|
use gpui_component::notification::NotificationType;
|
||||||
|
use gpui_component::{Root, StyledExt, Theme, WindowExt};
|
||||||
use settings::{AppearanceMode, SettingsStore};
|
use settings::{AppearanceMode, SettingsStore};
|
||||||
use signed_state::{Backend, BackendEvent};
|
use signed_state::{Backend, BackendEvent};
|
||||||
|
|
||||||
@@ -66,6 +67,10 @@ impl Workspace {
|
|||||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||||
passphrase_dialog::open(window, cx);
|
passphrase_dialog::open(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let BackendEvent::Error(message) = event {
|
||||||
|
window.push_notification((NotificationType::Error, message.clone()), cx);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## `BackendEvent::SyncProgress`
|
|
||||||
|
|
||||||
File: `crates/signed_state/src/backend.rs`
|
|
||||||
|
|
||||||
Kept intentionally. The progress pipeline (the `SyncProgress` variant, the `sync_progress`
|
|
||||||
field and its accessor, and the progress task in `sync_bootstrap`) is retained for a planned
|
|
||||||
sync progress indicator. No subscriber exists yet. Do not remove it without revisiting that
|
|
||||||
plan.
|
|
||||||
|
|
||||||
## `login` / `logout` family
|
|
||||||
|
|
||||||
File: `crates/signed_state/src/backend.rs`
|
|
||||||
|
|
||||||
No UI path calls these. `import_dialog::open` is an empty stub. Decide whether to
|
|
||||||
delete the family (`login`, `login_with_new_identity`, `login_with_nsec`,
|
|
||||||
`login_with_bunker`, `logout`) or wire the stub to `Backend::login`.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user