15 Commits
Author SHA1 Message Date
reya 4d8aa97e24 feat: add sync detection for repositories (#28)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Reviewed-on: #28
2026-10-08 02:34:33 +00:00
reya 31256a4870 chore: clean up core crate
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
2026-10-07 16:46:29 +07:00
reya 5586f67f0e feat: allow choosing the default branch when publishing a repo
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-07 11:52:53 +07:00
reya 5887a14c50 chore: refactor git module (#27)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #27
2026-10-07 02:40:12 +00:00
reya a706fadafa chore: upgrade gpui-component to 0.7 and gix to 0.88
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-07 05:19:15 +07:00
reya 8900d1f3ee feat: add a quit action
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 17:19:21 +07:00
reya 237b6a6996 feat: add user menu and sign-out support
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 15:50:30 +07:00
reya 62f355d70e chore: only refresh checkouts when related settings change
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 14:46:00 +07:00
reya f2a0ed002d chore: load user data via gossip and defer grasp relay connection
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 13:48:35 +07:00
reya cd72b25626 chore: remove trivial single-use helper functions
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 10:20:28 +07:00
reya a4df8e5731 chore: update changelog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 12:46:09 +07:00
reya d4ba71a362 feat: add identity import dialog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 10:55:32 +07:00
reya 6ec4b7e813 chore: adjust default theme
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 08:27:27 +07:00
reya c76e909f7f chore: refactor backend around domain types (#26)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #26
2026-10-03 10:35:48 +00:00
reya 6c4cc9f2ca chore: update deps
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-27 15:48:51 +07:00
93 changed files with 8223 additions and 8376 deletions
+3
View File
@@ -2,7 +2,10 @@
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified. * Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious. * Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files. * Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `CheckoutsStore::global(cx)` inline.
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors. * Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
* Be careful with operations like indexing which may panic if the indexes are out of bounds. * Be careful with operations like indexing which may panic if the indexes are out of bounds.
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately: * Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
+30 -2
View File
@@ -6,6 +6,36 @@
### Added ### Added
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
- Add a dropdown menu on the signed-in user button in the sidebar
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
- Allow choosing the default branch when publishing a repository
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
### Changed
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
- 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`
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
- Refresh the checkouts store only when the checkouts settings change
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
### Fixed
### 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
## v0.2.0-alpha - 2026/09/27
### Added
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise - Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default - Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default) - Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
@@ -29,8 +59,6 @@
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34 - Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
### Deprecated
## v0.1.0-alpha - 2026/09/14 ## v0.1.0-alpha - 2026/09/14
Initial alpha release. Initial alpha release.
Generated
+527 -567
View File
File diff suppressed because it is too large Load Diff
+6 -11
View File
@@ -9,26 +9,25 @@ edition = "2024"
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.6" } gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.6", features = ["font-kit", "x11", "wayland"] } gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.6" } reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
# GPUI Kit # GPUI Kit
gpui-component = { version = "0.6.6", features = ["tree-sitter-languages"] } gpui-component = { version = "0.7.1", features = ["tree-sitter-languages"] }
gpui-base = "0.6.6" gpui-base = "0.7.1"
dock = { path = "crates/dock" } dock = { path = "crates/dock" }
settings = { path = "crates/settings" } 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" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] } gix = { version = "0.88", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
smol = "2" smol = "2"
futures = "0.3" futures = "0.3"
@@ -45,10 +44,6 @@ tracing = "0.1"
errno = { version = "0.3.14", default-features = false } errno = { version = "0.3.14", default-features = false }
instant = "0.1" instant = "0.1"
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
[profile.release] [profile.release]
strip = true strip = true
opt-level = "z" opt-level = "z"
+8 -18
View File
@@ -13,8 +13,8 @@ use gpui_base::dock::{
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState, DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
PanelView, TabGroupRenderer, PanelView, TabGroupRenderer,
}; };
use gpui_base::resize_handle; use gpui_base::{HandleEdge, resize_handle};
use gpui_component::{ActiveTheme as _, Side}; use gpui_component::ActiveTheme as _;
use crate::invalid_panel::InvalidPanel; use crate::invalid_panel::InvalidPanel;
use crate::panel_handle; use crate::panel_handle;
@@ -40,11 +40,6 @@ impl SkinShared {
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> { pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
&self.resizing_dock &self.resizing_dock
} }
/// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
pub(crate) fn notify(&self, cx: &mut App) {
_ = self.area.update(cx, |_, cx| cx.notify());
}
} }
/// The Signed appearance for a [`DockArea`]. /// The Signed appearance for a [`DockArea`].
@@ -74,16 +69,6 @@ impl SignedDockSkin {
pub(crate) fn shared(&self) -> &Rc<SkinShared> { pub(crate) fn shared(&self) -> &Rc<SkinShared> {
&self.shared &self.shared
} }
/// Whether tab bars offer the affordance that collapses a neighbouring dock.
pub fn is_toggle_button_visible(&self) -> bool {
self.shared.is_toggle_button_visible()
}
pub fn set_toggle_button_visible(&self, visible: bool, cx: &mut App) {
self.shared.toggle_button_visible.set(visible);
self.shared.notify(cx);
}
} }
/// Payload a dock's resize handle drags. /// Payload a dock's resize handle drags.
@@ -179,7 +164,12 @@ impl SignedDockSkin {
let shared = self.shared().clone(); let shared = self.shared().clone();
resize_handle("resize-handle", placement.axis()) resize_handle("resize-handle", placement.axis())
.when(placement.is_left(), |this| this.placement(Side::Left)) .when(placement.is_left(), |this| {
this.inside(HandleEdge::Trailing)
})
.when(placement.is_right() || placement.is_bottom(), |this| {
this.inside(HandleEdge::Leading)
})
.on_drag(ResizePanel, move |info, _, _, cx| { .on_drag(ResizePanel, move |info, _, _, cx| {
cx.stop_propagation(); cx.stop_propagation();
shared.resizing_dock().set(Some(placement)); shared.resizing_dock().set(Some(placement));
-13
View File
@@ -25,19 +25,6 @@ pub fn add_center_panel(
area.add_panel_view(panel, DockPlacement::Center, None, window, cx); area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
} }
/// Add an already-wrapped panel handle to the bottom dock of `area`.
///
/// Used for sub-views that hang under the center, such as the inbox's Unread
/// and Archived lists.
pub fn add_bottom_panel(
area: &mut DockArea,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<DockArea>,
) {
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
}
/// The fixed height of the tab bar, which doubles as the window title bar. /// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.); pub const TAB_BAR_HEIGHT: Pixels = px(44.);
+3 -6
View File
@@ -71,11 +71,6 @@ impl Render for DragPanelPreview {
} }
} }
/// The zoom affordance for the group's displayed panel, if it offers one.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
panel_zoom_control(group.active_panel()?, cx)
}
/// The zoom affordance `panel` offers, if any. /// The zoom affordance `panel` offers, if any.
/// ///
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise. /// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
@@ -542,7 +537,9 @@ impl SignedTabGroupSkin {
impl TabGroupRenderer for SignedTabGroupSkin { impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> { fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx); let control = group
.active_panel()
.and_then(|panel| panel_zoom_control(panel, cx));
// An emptied group draws nothing, so no bare tab bar is left behind. // An emptied group draws nothing, so no bare tab bar is left behind.
if group.panels().is_empty() { if group.panels().is_empty() {
return div().id("tab-panel"); return div().id("tab-panel");
+3 -3
View File
@@ -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()
+8 -9
View File
@@ -28,7 +28,6 @@ pub enum EventFetchingStrategy {
Uncensored, Uncensored,
} }
/// Fields mirror the gpui-component `Theme` surface customized at startup.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct ThemeSettings { pub struct ThemeSettings {
@@ -46,6 +45,7 @@ pub struct ThemeSettings {
pub radius_lg: f32, pub radius_lg: f32,
/// Whether focused controls draw a ring outside their border. /// Whether focused controls draw a ring outside their border.
pub focus_ring: bool, pub focus_ring: bool,
/// Whether to apply a shadow to elements.
pub shadow: bool, pub shadow: bool,
} }
@@ -56,8 +56,8 @@ impl Default for ThemeSettings {
dark_theme: "Signed Dark".into(), dark_theme: "Signed Dark".into(),
font_size: 16.0, font_size: 16.0,
mono_font_size: 13.0, mono_font_size: 13.0,
radius: 2.0, radius: 4.0,
radius_lg: 6.0, radius_lg: 8.0,
focus_ring: false, focus_ring: false,
shadow: false, shadow: false,
} }
@@ -102,18 +102,17 @@ impl Default for GraspServersSettings {
#[serde(default)] #[serde(default)]
pub struct LocalReposSettings { pub struct LocalReposSettings {
/// The directories scanned for local git repositories, /// The directories scanned for local git repositories,
/// defaults to the user's Desktop and Documents folders. /// Defaults:
///
/// - `~/Desktop`
/// - `~/Documents`
pub scan_paths: Vec<PathBuf>, pub scan_paths: Vec<PathBuf>,
} }
fn default_scan_paths() -> Vec<PathBuf> {
vec![paths::desktop_dir(), paths::documents_dir()]
}
impl Default for LocalReposSettings { impl Default for LocalReposSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
scan_paths: default_scan_paths(), scan_paths: vec![paths::desktop_dir(), paths::documents_dir()],
} }
} }
} }
+1
View File
@@ -7,3 +7,4 @@ publish.workspace = true
[dependencies] [dependencies]
nostr.workspace = true nostr.workspace = true
serde.workspace = true serde.workspace = true
utils = { path = "../utils" }
+92 -17
View File
@@ -1,23 +1,98 @@
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())
}
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
}
} }
+492
View File
@@ -0,0 +1,492 @@
use std::collections::HashSet;
use nostr::prelude::*;
use crate::RepoAddr;
use crate::upstream::Upstream;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
pub event_id: EventId,
pub id: String,
pub owner: PublicKey,
pub created_at: Timestamp,
pub name: Option<String>,
pub description: Option<String>,
pub web: Vec<Url>,
pub clone: Vec<Url>,
pub relays: Vec<RelayUrl>,
pub euc: Option<String>,
pub maintainers: Vec<PublicKey>,
pub upstream: Option<Upstream>,
pub hashtags: Vec<String>,
}
impl Announcement {
/// Lists forks of the base, the user's own 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()
}
/// Parses a kind 30617 event, rejecting other kinds and missing `d` tags.
pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement {
return None;
}
let id = event.tags.identifier()?;
let mut hashtags: Vec<String> = Vec::new();
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut web: Vec<Url> = Vec::new();
let mut clone: Vec<Url> = Vec::new();
let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new();
let mut upstream: Option<Upstream> = None;
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Name(value)) => name = Some(value),
Ok(Nip34Tag::Description(value)) => description = Some(value),
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
_ => {}
}
if upstream.is_none() && tag.kind() == "u" {
let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default();
if !raw.is_empty() {
upstream = Some(Upstream::parse(raw));
}
}
}
Some(Self {
event_id: event.id,
owner: event.pubkey,
created_at: event.created_at,
id,
name,
description,
web,
clone,
relays,
euc,
maintainers,
upstream,
hashtags,
})
}
/// Returns the owner and identifier as a repository address.
pub fn addr(&self) -> RepoAddr {
RepoAddr::new(self.owner, self.id.clone())
}
/// Returns the name, defaulting to "Untitled".
pub fn name(&self) -> String {
self.name.clone().unwrap_or("Untitled".into())
}
/// Matches a `u` tag pointing at the base or a shared earliest unique commit.
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
if self.addr() == *base {
return false;
}
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
return true;
}
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
}
/// Returns the description, defaulting to "No description".
pub fn description(&self) -> String {
self.description
.clone()
.unwrap_or("No description".to_string())
}
/// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork.
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
maintainers.push(self.owner);
}
maintainers
}
/// Returns deduplicated `git clone` commands for the advertised URLs.
pub fn clone_urls(&self) -> Vec<String> {
let mut seen = HashSet::new();
self.clone
.iter()
.map(|url| format!("git clone {url}"))
.filter(|command| seen.insert(command.clone()))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
/// Returns the test signer keys.
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Builds a signed repo announcement event from raw tags.
fn announcement_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
/// Parses an event carrying every supported tag.
#[test]
fn parses_full_announcement() {
let event = announcement_event(&[
&["d", "my-repo"],
&["name", "My Repo"],
&["description", "A test repository"],
&["web", "https://example.com/repo"],
&["clone", "https://example.com/repo.git"],
&["relays", "wss://relay.example.com"],
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
&["maintainers", MAINTAINER_HEX],
&["t", "rust"],
&["t", "nostr"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(announcement.owner, keys().public_key());
assert_eq!(announcement.id, "my-repo");
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
assert_eq!(
announcement.description.as_deref(),
Some("A test repository")
);
assert_eq!(
announcement.web,
vec![Url::parse("https://example.com/repo").unwrap()]
);
assert_eq!(
announcement.clone,
vec![Url::parse("https://example.com/repo.git").unwrap()]
);
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
);
assert_eq!(
announcement.euc.as_deref(),
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
);
assert_eq!(
announcement.maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
}
/// Skips malformed clone, relay, and maintainer values.
#[test]
fn drops_malformed_values() {
let event = announcement_event(&[
&["d", "my-repo"],
&["clone", "not a url"],
&["relays", "wss://good.example.com"],
&["maintainers", "not-a-pubkey"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert!(announcement.clone.is_empty());
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
);
assert!(announcement.maintainers.is_empty());
}
/// Parses the `u` tag into an upstream coordinate.
#[test]
fn parses_upstream_tag() {
let event = announcement_event(&[
&["d", "my-fork"],
&[
"u",
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
"wss://relay.example.com",
],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag");
assert_eq!(
upstream.addr,
Some(RepoAddr::new(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream"
))
);
assert_eq!(
upstream.raw,
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
);
assert_eq!(
upstream.display().to_string(),
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
);
}
/// Matches a fork through its `u` tag coordinate.
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
let base = RepoAddr::new(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
let fork = Announcement::from_event(&event).expect("parses");
assert!(fork.is_fork_of(&base, None));
}
/// Matches forks sharing the base's earliest unique commit.
#[test]
fn is_fork_of_matches_a_shared_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
let base = Announcement::from_event(&base_event).expect("parses");
let base_addr = base.addr();
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
let fork = Announcement::from_event(&fork_event).expect("parses");
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
let other_event = announcement_event(&[
&["d", "other"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let other = Announcement::from_event(&other_event).expect("parses");
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
assert!(!fork.is_fork_of(&base_addr, None));
}
/// Matches permanent forks whose `u` tag points at the base despite a diverged EUC.
#[test]
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
let base = RepoAddr::new(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let event = announcement_event(&[
&["d", "my-fork"],
&["u", &base.to_string()],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let fork = Announcement::from_event(&event).expect("parses");
assert!(fork.is_fork_of(&base, Some(base_euc)));
}
/// Includes the owner among maintainers for primary repositories.
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
assert_eq!(maintainers.len(), 2);
assert!(maintainers.contains(&announcement.owner));
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
}
/// Excludes the owner from maintainers for subordinate forks.
#[test]
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
&["maintainers", MAINTAINER_HEX],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
assert!(!maintainers.contains(&announcement.owner));
assert_eq!(
maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
}
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
/// Builds an announcement event signed by the given owner key.
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
/// Builds one announcement owned by the owner key at the given index.
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
vec![
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
.expect("parses"),
]
}
/// Places the user's own forks before everyone else's.
#[test]
fn fork_candidates_orders_own_forks_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let clone = "https://grasp.example/npub1x/my-fork.git";
let base_addr = RepoAddr::new(
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
"upstream",
);
let all = vec![
owned_announcements(
2,
&[
&["d", "other-project"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
1,
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "their-fork"],
&["u", &base_addr.to_string()],
&["clone", clone],
],
)
.pop()
.unwrap(),
];
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
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();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
/// Excludes the base itself, unrelated repos, and repos without clone URLs.
#[test]
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
let base_addr = RepoAddr::new(base_owner, "upstream");
let mut all = vec![
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "other"],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "mirror"],
&["r", euc, "euc"],
&["clone", "https://grasp.example/x/mirror.git"],
],
)
.pop()
.unwrap(),
];
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner));
assert_eq!(forks.len(), 1);
assert_eq!(forks[0].id, "mirror");
all.push(
owned_announcements(
2,
&[
&["d", "u-fork"],
&["u", &base_addr.to_string()],
&["clone", "https://grasp.example/x/u-fork.git"],
],
)
.pop()
.unwrap(),
);
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();
assert_eq!(ids, vec!["u-fork"]);
}
}
+8 -19
View File
@@ -2,26 +2,18 @@ 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.
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. /// Builds a [`Deletions`] instance from a collection of events.
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self { pub fn from_events<E>(events: E) -> Self
where
E: IntoIterator<Item = Event>,
{
let mut ids = HashSet::new(); let mut ids = HashSet::new();
let mut coords = Vec::new(); let mut coords = Vec::new();
let mut vanished = Vec::new(); let mut vanished = Vec::new();
@@ -36,8 +28,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 +41,7 @@ impl Deletions {
} }
} }
/// Whether the event is covered by a valid deletion or vanish request. /// Returns whether the given event is deleted.
/// A request is valid when its author matches the deleted event's author, per NIP-09.
///
/// 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
+117 -175
View File
@@ -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,184 +40,140 @@ 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_kind(event: &Event, name: &str) -> Option<Kind> {
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
event event
.tags .tags
.iter() .iter()
.find(|tag| tag.kind() == name) .find(|tag| tag.kind() == name)
.and_then(|tag| tag.content()) .and_then(|tag| tag.content())
.and_then(|value| value.parse::<Kind>().ok())
} }
/// Kind named by the first tag `name` on `event`. pub struct Filters;
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
tag_value(event, name)?.parse::<Kind>().ok()
}
/// Latest announcement event for a repository. impl Filters {
pub fn announcement(addr: &RepoAddr) -> Filter { const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(addr.public_key)
.identifier(addr.identifier.clone())
}
/// Latest state event for a repository, carrying refs and HEAD. pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
pub fn state(addr: &RepoAddr) -> Filter { Filter::new()
Filter::new() .kinds([
.kind(Kind::RepoState) Kind::GitStatusOpen,
.author(addr.public_key) Kind::GitStatusApplied,
.identifier(addr.identifier.clone()) Kind::GitStatusClosed,
} Kind::GitStatusDraft,
])
/// All NIP-34 activity addressed to a repository via its `#a` tag. .events(roots)
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
/// The `a` tag is optional on status events per NIP-34.
/// Statuses published without it are not matched here.
pub fn activity(addr: &RepoAddr) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
}
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
/// 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)
}
/// NIP-51: code (people who produce NIP-34 events) follow list
pub fn git_authors(public_key: PublicKey) -> Filter {
Filter::new().kind(Kind::Custom(10017)).author(public_key)
}
/// NIP-51: NIP-34 followed repositories
pub fn git_repos(public_key: PublicKey) -> Filter {
Filter::new().kind(Kind::Custom(10018)).author(public_key)
}
/// Replaceable events, so the latest of each kind is all we need.
pub fn user_metadata(public_key: PublicKey) -> Vec<Filter> {
vec![
Self::grasp_list(public_key).limit(1),
Self::git_authors(public_key).limit(1),
Self::git_repos(public_key).limit(1),
Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1),
Filter::new()
.kind(Kind::Metadata)
.author(public_key)
.limit(1),
Filter::new()
.kind(Kind::MuteList)
.author(public_key)
.limit(1),
Filter::new()
.kind(Kind::BlossomServerList)
.author(public_key)
.limit(1),
]
}
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"])
}
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
Self::notification_comments(me),
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
]
}
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)
}
fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
}
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 +202,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 +221,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()
)));
} }
} }
+145
View File
@@ -0,0 +1,145 @@
use nostr::prelude::*;
pub trait GitEvent {
/// Returns the NIP-34 subject or the first non-empty content line.
fn activity_subject(&self) -> String;
/// Returns the current commit id.
fn current_commit(&self) -> Option<String>;
/// Returns the merge base commit id.
fn merge_base(&self) -> Option<String>;
/// Returns the advertised clone URLs.
fn clone_urls(&self) -> Option<Vec<Url>>;
/// Returns the branch name.
fn branch_name(&self) -> Option<String>;
/// Returns whether the event is git activity.
fn is_git_activity(&self) -> bool;
/// Returns whether an `e` or `E` tag points at the root event.
fn references_root(&self, root: &EventId) -> bool;
}
impl GitEvent for Event {
/// Reads the subject tag, then the first content line, then "Untitled".
fn activity_subject(&self) -> String {
let subject = self
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Subject(subject)) => Some(subject),
_ => 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())
}
/// Reads the `commit` tag.
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,
})
}
/// Reads the `merge-base` tag.
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,
})
}
/// Reads the `clone` tag.
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,
})
}
/// Reads the `branch-name` tag.
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,
})
}
/// Classifies git issue, patch, pull request, comment, and status kinds.
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,
}
}
/// Compares `e` and `E` tag contents against the root id.
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 {
/// Delegates to the referenced event.
fn activity_subject(&self) -> String {
(*self).activity_subject()
}
/// Delegates to the referenced event.
fn current_commit(&self) -> Option<String> {
(*self).current_commit()
}
/// Delegates to the referenced event.
fn merge_base(&self) -> Option<String> {
(*self).merge_base()
}
/// Delegates to the referenced event.
fn clone_urls(&self) -> Option<Vec<Url>> {
(*self).clone_urls()
}
/// Delegates to the referenced event.
fn branch_name(&self) -> Option<String> {
(*self).branch_name()
}
/// Delegates to the referenced event.
fn is_git_activity(&self) -> bool {
(*self).is_git_activity()
}
/// Delegates to the referenced event.
fn references_root(&self, root: &EventId) -> bool {
(*self).references_root(root)
}
}
-672
View File
@@ -1,672 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{RepoAddr, activity_subject};
/// Window before `now` that an advanced cutoff retreats to.
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);
/// A thread of notification and own-activity events sharing one root.
#[derive(Debug, Clone)]
pub struct InboxItem {
/// The root issue, patch or pull request the events belong to.
pub root: EventId,
/// The root event itself, when it is known locally.
pub root_event: Option<Event>,
/// Repository the root belongs to, from the root's `a` tag.
pub address: Option<RepoAddr>,
/// Notification events directed at the user, newest first.
pub events: Vec<Event>,
/// The user's own events in the thread, newest first.
pub own_events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
/// Whether every notification event in the thread is archived.
pub archived: bool,
}
impl InboxItem {
/// Title of the thread, read from its root issue/patch/PR when known.
pub fn title(&self) -> String {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(activity_subject)
.unwrap_or_else(|| "Untitled".to_string())
}
pub fn kind(&self) -> Option<Kind> {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
}
/// Timestamp of the newest event in the thread.
pub fn latest_activity(&self) -> Timestamp {
self.root_event
.as_ref()
.into_iter()
.chain(self.own_events.first())
.chain(self.events.first())
.map(|event| event.created_at)
.max()
.unwrap_or_default()
}
/// Up to `limit` events of the thread, oldest first.
pub fn timeline(&self, limit: usize) -> Vec<Event> {
let mut seen: HashSet<EventId> = HashSet::new();
let mut events: Vec<Event> = Vec::new();
if let Some(root) = &self.root_event {
seen.insert(root.id);
events.push(root.clone());
}
let mut rest: Vec<Event> = self
.own_events
.iter()
.chain(self.events.iter())
.filter(|event| seen.insert(event.id))
.cloned()
.collect();
rest.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
rest.truncate(limit.saturating_sub(events.len()));
events.extend(rest);
events.sort_by_key(|event| event.created_at);
events
}
/// Whether the thread has an unread event still visible in the inbox.
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
.iter()
.rev()
.filter(|event| !state.is_read(event))
.map(|event| event.id)
.collect();
// A thread without notification events is never archived.
self.archived =
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
}
}
/// Root issue, patch or pull request of a notification event.
///
/// Returns `None` when the event is not git-related, or when its root is a
/// coordinate rather than an event.
///
/// - 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
L: Fn(EventId) -> Option<Event>,
{
match event.kind {
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
Kind::GitPatch => Some(match first_e_id(event) {
Some(parent) => resolve_thread_root(parent, lookup),
None => event.id,
}),
Kind::Comment => match nip22::extract_root(event) {
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
_ => None,
},
Kind::GitPullRequestUpdate => {
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
}
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => {
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
}
_ => None,
}
}
/// Group notification events and the user's own events into one item per thread.
pub fn group<E, O, L>(
events: E,
own: O,
me: PublicKey,
state: &InboxReadState,
lookup: &L,
) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
O: IntoIterator<Item = Event>,
L: Fn(EventId) -> Option<Event>,
{
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in events {
if event.pubkey == me {
continue;
}
let Some(root) = notification_root(&event, lookup) else {
continue;
};
groups.entry(root).or_default().push(event);
}
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in own {
let root = notification_root(&event, lookup).unwrap_or(event.id);
own_groups.entry(root).or_default().push(event);
}
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
roots.sort();
roots.dedup();
let mut items: Vec<InboxItem> = roots
.into_iter()
.map(|root| {
let mut events = groups.remove(&root).unwrap_or_default();
let mut own_events = own_groups.remove(&root).unwrap_or_default();
sort_newest_first(&mut events);
sort_newest_first(&mut own_events);
let root_event = lookup(root);
let mut item = InboxItem {
root,
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next()),
root_event,
events,
own_events,
unread_ids: Vec::new(),
archived: false,
};
item.apply_state(state);
item
})
.collect();
items.sort_by(|a, b| {
b.latest_activity()
.cmp(&a.latest_activity())
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
});
items
}
/// Sort thread events newest first, ties broken by id.
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()))
});
}
/// Read and archive state of the inbox, a high-water-mark model.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)]
pub read_before: Timestamp,
#[serde(default)]
pub read_ids: HashSet<EventId>,
#[serde(default)]
pub archived_before: Timestamp,
#[serde(default)]
pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
/// Whether `event` is at or before the read cutoff, or marked read.
pub fn is_read(&self, event: &Event) -> bool {
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 {
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) {
if event.created_at > self.read_before {
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) {
if event.created_at > self.archived_before {
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) {
let cutoff = now - MARK_ALL_WINDOW;
self.read_before = cutoff;
self.read_ids = all
.iter()
.filter(|event| event.pubkey != me && event.created_at > cutoff)
.map(|event| event.id)
.collect();
}
/// Advance the read cutoff to the newest point that keeps unread events
/// unread, then prune the id set.
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));
self.read_before = 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) {
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
self.is_archived(event)
});
self.archived_before = cutoff;
prune_ids(&mut self.archived_ids, all, cutoff);
}
}
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
fn advance_cutoff<M>(
all: &[Event],
me: PublicKey,
now: Timestamp,
current: Timestamp,
is_marked: M,
) -> Timestamp
where
M: Fn(&Event) -> bool,
{
let fallback = now - ADVANCE_WINDOW;
let oldest = all
.iter()
.filter(|event| event.pubkey != me && !is_marked(event))
.map(|event| event.created_at)
.min();
let candidate = match oldest {
Some(at) if at < fallback => at - 1,
_ => fallback,
};
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) {
let created_at: HashMap<EventId, Timestamp> = all
.iter()
.map(|event| (event.id, event.created_at))
.collect();
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)]
mod tests {
use super::*;
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
.expect("valid e tag")
}
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
fn issue(author: &Keys, at: u64) -> Event {
signed(author, Kind::GitIssue, Vec::new(), at)
}
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
signed(
author,
Kind::GitIssue,
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
at,
)
}
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
let comment = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("valid K tag"),
],
200,
);
let events = [issue.clone(), comment.clone()];
assert_eq!(
notification_root(&comment, &lookup(&events)),
Some(issue.id)
);
}
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
let events = [root_patch.clone(), child_patch.clone()];
assert_eq!(
notification_root(&child_patch, &lookup(&events)),
Some(root_patch.id)
);
}
#[test]
fn status_resolves_via_the_root_marker() {
let issue = issue(&keys(1), 100);
let status = signed(
&keys(2),
Kind::GitStatusClosed,
vec![marked_e_tag(&issue, "root")],
200,
);
let events = [issue.clone(), status.clone()];
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
}
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
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 events = [issue.clone(), reply, nested.clone()];
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
}
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
let issue = titled_issue(&me, "Add retry logic", 100);
let mine = signed(
&me,
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
150,
);
let reply = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
200,
);
let context = [issue.clone(), mine.clone(), reply.clone()];
let items = group(
[reply.clone()],
[issue.clone(), mine.clone()],
me.public_key(),
&InboxReadState::default(),
&lookup(&context),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].root, issue.id);
assert_eq!(
items[0].root_event.as_ref().map(|event| event.id),
Some(issue.id)
);
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
assert_eq!(items[0].title(), "Add retry logic");
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]
.timeline(5)
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
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]));
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::collections::HashMap;
use nostr::prelude::*;
use utils::sort_newest_first;
use super::{InboxItem, InboxReadState, ThreadResolver};
use crate::RepoAddr;
/// Groups events into inbox threads, merging the user's own events.
pub fn group<E, O, L>(
events: E,
own: O,
me: PublicKey,
state: &InboxReadState,
lookup: &L,
) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
O: IntoIterator<Item = Event>,
L: Fn(EventId) -> Option<Event>,
{
let resolver = ThreadResolver::new(lookup);
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in events {
if event.pubkey == me {
continue;
}
let Some(root) = resolver.notification_root(&event) else {
continue;
};
groups.entry(root).or_default().push(event);
}
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in own {
let root = resolver.notification_root(&event).unwrap_or(event.id);
own_groups.entry(root).or_default().push(event);
}
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
roots.sort();
roots.dedup();
let mut items: Vec<InboxItem> = roots
.into_iter()
.map(|root| {
let mut events = groups.remove(&root).unwrap_or_default();
let mut own_events = own_groups.remove(&root).unwrap_or_default();
sort_newest_first(&mut events);
sort_newest_first(&mut own_events);
let root_event = lookup(root);
let mut item = InboxItem {
root,
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next())
.map(RepoAddr::from),
root_event,
events,
own_events,
unread_ids: Vec::new(),
archived: false,
};
item.apply_state(state);
item
})
.collect();
items.sort_by(|a, b| {
b.latest_activity()
.cmp(&a.latest_activity())
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
});
items
}
#[cfg(test)]
mod tests {
use super::*;
/// Derives deterministic signer keys from a seed byte.
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
/// Builds a signed event with a fixed timestamp.
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
/// Builds an uppercase `E` tag for the event.
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
/// Builds an event lookup closure over a slice.
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
/// Builds a git issue event with a subject tag.
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
signed(
author,
Kind::GitIssue,
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
at,
)
}
/// Merges the user's own events into the matching thread.
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
let issue = titled_issue(&me, "Add retry logic", 100);
let mine = signed(
&me,
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
150,
);
let reply = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
200,
);
let context = [issue.clone(), mine.clone(), reply.clone()];
let items = group(
[reply.clone()],
[issue.clone(), mine.clone()],
me.public_key(),
&InboxReadState::default(),
&lookup(&context),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].root, issue.id);
assert_eq!(
items[0].root_event.as_ref().map(|event| event.id),
Some(issue.id)
);
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
assert_eq!(items[0].title(), "Add retry logic");
assert_eq!(items[0].events, vec![reply.clone()]);
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
assert_eq!(
items[0]
.timeline(5)
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
vec![issue.id, mine.id, reply.id]
);
}
}
+99
View File
@@ -0,0 +1,99 @@
use std::collections::HashSet;
use nostr::prelude::*;
use super::InboxReadState;
use crate::{GitEvent, RepoAddr};
#[derive(Debug, Clone)]
pub struct InboxItem {
pub root: EventId,
pub root_event: Option<Event>,
pub address: Option<RepoAddr>,
pub events: Vec<Event>,
pub own_events: Vec<Event>,
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
impl InboxItem {
/// Returns the thread title from the root, own events, or events.
pub fn title(&self) -> String {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.activity_subject())
.unwrap_or_else(|| "Untitled".to_string())
}
/// Returns the kind of the thread's primary event.
pub fn kind(&self) -> Option<Kind> {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
}
/// Returns the newest timestamp among the root, own, and other events.
pub fn latest_activity(&self) -> Timestamp {
self.root_event
.as_ref()
.into_iter()
.chain(self.own_events.first())
.chain(self.events.first())
.map(|event| event.created_at)
.max()
.unwrap_or_default()
}
/// Builds a chronological timeline capped at `limit` events, root first.
pub fn timeline(&self, limit: usize) -> Vec<Event> {
let mut seen: HashSet<EventId> = HashSet::new();
let mut events: Vec<Event> = Vec::new();
if let Some(root) = &self.root_event {
seen.insert(root.id);
events.push(root.clone());
}
let mut rest: Vec<Event> = self
.own_events
.iter()
.chain(self.events.iter())
.filter(|event| seen.insert(event.id))
.cloned()
.collect();
rest.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
rest.truncate(limit.saturating_sub(events.len()));
events.extend(rest);
events.sort_by_key(|event| event.created_at);
events
}
/// Returns whether the thread has unread events and is not archived.
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
/// Recomputes unread ids and the archived flag from the read state.
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
.iter()
.rev()
.filter(|event| !state.is_read(event))
.map(|event| event.id)
.collect();
self.archived =
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
}
}
+9
View File
@@ -0,0 +1,9 @@
mod group;
mod item;
mod read_state;
mod resolver;
pub use group::group;
pub use item::InboxItem;
pub use read_state::InboxReadState;
pub use resolver::ThreadResolver;
+109
View File
@@ -0,0 +1,109 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)]
pub read_before: Timestamp,
#[serde(default)]
pub read_ids: HashSet<EventId>,
#[serde(default)]
pub archived_before: Timestamp,
#[serde(default)]
pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
/// Returns whether the event falls under the read high-water mark or id set.
pub fn is_read(&self, event: &Event) -> bool {
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
}
/// Returns whether the event falls under the archived high-water mark or id set.
pub fn is_archived(&self, event: &Event) -> bool {
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
}
/// Records the event as read when it is past the current cutoff.
pub fn mark_read(&mut self, event: &Event) {
if event.created_at > self.read_before {
self.read_ids.insert(event.id);
}
}
/// Records the event as archived when it is past the current cutoff.
pub fn mark_archived(&mut self, event: &Event) {
if event.created_at > self.archived_before {
self.archived_ids.insert(event.id);
}
}
/// Marks all events from other authors within the window as read.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = now - MARK_ALL_WINDOW;
self.read_before = cutoff;
self.read_ids = all
.iter()
.filter(|event| event.pubkey != me && event.created_at > cutoff)
.map(|event| event.id)
.collect();
}
/// Advances the read cutoff as far as possible, then prunes the id set.
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));
self.read_before = cutoff;
prune_ids(&mut self.read_ids, all, cutoff);
}
/// Advances the archived cutoff as far as possible, then prunes the id set.
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
self.is_archived(event)
});
self.archived_before = cutoff;
prune_ids(&mut self.archived_ids, all, cutoff);
}
}
/// Computes the newest cutoff that leaves unmarked events uncovered.
fn advance_cutoff<M>(
all: &[Event],
me: PublicKey,
now: Timestamp,
current: Timestamp,
is_marked: M,
) -> Timestamp
where
M: Fn(&Event) -> bool,
{
let fallback = now - ADVANCE_WINDOW;
let oldest = all
.iter()
.filter(|event| event.pubkey != me && !is_marked(event))
.map(|event| event.created_at)
.min();
let candidate = match oldest {
Some(at) if at < fallback => at - 1,
_ => fallback,
};
candidate.max(current)
}
/// Drops ids whose events predate the cutoff.
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
let created_at: HashMap<EventId, Timestamp> = all
.iter()
.map(|event| (event.id, event.created_at))
.collect();
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
}
+249
View File
@@ -0,0 +1,249 @@
use std::collections::HashSet;
use nostr::prelude::*;
pub struct ThreadResolver<'a, L: ?Sized> {
lookup: &'a L,
}
impl<'a, L> ThreadResolver<'a, L>
where
L: Fn(EventId) -> Option<Event> + ?Sized,
{
/// Creates a resolver over the given event lookup.
pub fn new(lookup: &'a L) -> Self {
Self { lookup }
}
/// Maps a git event kind to the thread root it notifies about.
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,
}
}
/// Follows parent pointers until a git issue or pull request root is reached.
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
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,
}
}
}
/// Mirrors gitworkshop's `getParentId` parent resolution.
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)
}
/// Reads the NIP-10 `root` marker, falling back to the first `e` tag.
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))
}
/// Reads the first lowercase `e` tag id.
fn first_e_id(&self, event: &Event) -> Option<EventId> {
self.first_tag_id(event, "e")
}
/// Reads the first uppercase `E` tag id.
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
self.first_tag_id(event, "E")
}
/// Reads the first event id from tags of the given kind.
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())
})
}
/// Parses a four-value `e` tag carrying the given marker.
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())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Derives deterministic signer keys from a seed byte.
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
/// Builds a signed event with a fixed timestamp.
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
/// Builds a plain lowercase `e` tag for the event.
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
/// Builds a four-value `e` tag with the given marker.
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
.expect("valid e tag")
}
/// Builds an uppercase `E` tag for the event.
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
/// Builds an event lookup closure over a slice.
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
/// Builds a git issue event.
fn issue(author: &Keys, at: u64) -> Event {
signed(author, Kind::GitIssue, Vec::new(), at)
}
/// Resolves a comment to its uppercase `E` root.
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
let comment = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("valid K tag"),
],
200,
);
let events = [issue.clone(), comment.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
Some(issue.id)
);
}
/// Resolves a child patch to its root patch.
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
let events = [root_patch.clone(), child_patch.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
Some(root_patch.id)
);
}
/// Resolves a status through its `root` marker tag.
#[test]
fn status_resolves_via_the_root_marker() {
let issue = issue(&keys(1), 100);
let status = signed(
&keys(2),
Kind::GitStatusClosed,
vec![marked_e_tag(&issue, "root")],
200,
);
let events = [issue.clone(), status.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&status),
Some(issue.id)
);
}
/// Follows a nested comment chain to the issue root.
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
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 events = [issue.clone(), reply, nested.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
Some(issue.id)
);
}
}
+13 -12
View File
@@ -1,20 +1,21 @@
pub mod addr; pub mod addr;
pub mod announcement;
pub mod deletions; pub mod deletions;
pub mod filters; pub mod filters;
pub mod git_event;
pub mod inbox; pub mod inbox;
pub mod model; pub mod pull_request;
pub mod state; pub mod state;
pub mod status; pub mod status;
pub mod upstream;
pub use addr::{RepoAddr, identifier_from_name, repo_addr}; pub use addr::RepoAddr;
pub use announcement::Announcement;
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 git_event::GitEvent;
}; pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
pub use inbox::{InboxItem, InboxReadState, group, notification_root}; pub use pull_request::PullRequest;
pub use model::{ pub use state::RepoState;
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, pub use status::RepoStatus;
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, pub use upstream::Upstream;
};
pub use state::{build_state, parse_state};
pub use status::{RepoStatus, references_root, resolve_status};
-883
View File
@@ -1,883 +0,0 @@
use std::collections::HashSet;
use nostr::prelude::*;
use crate::{RepoAddr, repo_addr};
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
/// ID of the announcement event itself.
pub event_id: EventId,
/// Repository ID, the `d` tag.
pub id: String,
/// Author of the announcement event.
pub owner: PublicKey,
/// When the announcement was published, used for latest-wins resolution.
pub created_at: Timestamp,
pub name: Option<String>,
pub description: Option<String>,
/// Webpage URLs for browsing.
pub web: Vec<Url>,
/// URLs for `git clone`.
pub clone: Vec<Url>,
/// Relays the repository monitors for patches and issues.
pub relays: Vec<RelayUrl>,
/// Earliest unique commit ID, the `r` tag with `euc` marker.
pub euc: Option<String>,
/// Other recognized maintainers.
pub maintainers: Vec<PublicKey>,
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
pub upstream: Option<Upstream>,
/// Hashtags labelling the repository, the `t` tags.
pub hashtags: Vec<String>,
}
/// The `u` tag of a fork announcement, per NIP-34.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
/// Raw first value of the `u` tag, a coordinate or git URL.
pub raw: String,
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
/// `None` for the git-URL form.
pub addr: Option<RepoAddr>,
}
impl Upstream {
fn parse(raw: &str) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
Self {
raw: raw.to_owned(),
addr,
}
}
pub fn display(&self) -> String {
match &self.addr {
Some(addr) => addr.to_string(),
None => self.raw.clone(),
}
}
}
/// Subject of a NIP-34 issue or pull request event.
/// Taken from the `subject` tag, else the first non-empty line of the content.
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
.or_else(|| {
event
.content
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|value| value.to_string())
})
.unwrap_or("Untitled".to_string())
}
/// The patch set of a pull request.
///
/// Returns an empty list when no patch event can be linked to the PR.
pub fn pull_request_patches<'a>(
pr: &Event,
patches: impl IntoIterator<Item = &'a Event>,
) -> Vec<&'a Event> {
let patches: Vec<&'a Event> = patches.into_iter().collect();
// The PR references its root patch via an `e` tag.
// Follow the NIP-10 reply chain forward from there.
// Each patch replies to the previous one, and among several replies the newest wins.
if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{
return forward_series(root, &patches);
}
// The PR has no `e` tag.
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
// Walk the reply chain backward to the root.
let Some(tip) = current_commit_of(pr) else {
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];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
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()
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
})
.max_by_key(|e| e.created_at)
}
/// The announced forks of `base` a new pull request compare can be built from.
///
/// 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);
}
}
own.into_iter().chain(others).collect()
}
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
///
/// It 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 {
/// Parse a kind `30617` event.
///
/// Returns `None` when the kind is wrong or the `d` tag is missing.
pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement {
return None;
}
let id = event.tags.identifier()?;
let mut hashtags: Vec<String> = Vec::new();
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut web: Vec<Url> = Vec::new();
let mut clone: Vec<Url> = Vec::new();
let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new();
let mut upstream: Option<Upstream> = None;
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Name(value)) => name = Some(value),
Ok(Nip34Tag::Description(value)) => description = Some(value),
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
_ => {}
}
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
// Only the first `u` tag is used.
if upstream.is_none() && tag.kind() == "u" {
let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default();
if !raw.is_empty() {
upstream = Some(Upstream::parse(raw));
}
}
}
Some(Self {
event_id: event.id,
owner: event.pubkey,
created_at: event.created_at,
id,
name,
description,
web,
clone,
relays,
euc,
maintainers,
upstream,
hashtags,
})
}
pub fn addr(&self) -> RepoAddr {
repo_addr(self.owner, self.id.clone())
}
/// The name of the repository, or a default if none is provided.
pub fn name(&self) -> String {
self.name.clone().unwrap_or("Untitled".into())
}
/// Whether this announcement is a fork of the repository at `base`.
/// 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 {
if self.addr() == *base {
return false;
}
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
return true;
}
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 {
self.description
.clone()
.unwrap_or("No description".to_string())
}
/// The effective maintainers of this repository,
/// the announced `maintainers` plus the announcement author.
///
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
maintainers.push(self.owner);
}
maintainers
}
/// The `git clone` URLs for this repository, deduplicated.
pub fn clone_urls(&self) -> Vec<String> {
let mut seen = HashSet::new();
self.clone
.iter()
.map(|url| format!("git clone {url}"))
.filter(|command| seen.insert(command.clone()))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
fn announcement_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
#[test]
fn parses_full_announcement() {
let event = announcement_event(&[
&["d", "my-repo"],
&["name", "My Repo"],
&["description", "A test repository"],
&["web", "https://example.com/repo"],
&["clone", "https://example.com/repo.git"],
&["relays", "wss://relay.example.com"],
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
&["maintainers", MAINTAINER_HEX],
&["t", "rust"],
&["t", "nostr"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(announcement.owner, keys().public_key());
assert_eq!(announcement.id, "my-repo");
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
assert_eq!(
announcement.description.as_deref(),
Some("A test repository")
);
assert_eq!(
announcement.web,
vec![Url::parse("https://example.com/repo").unwrap()]
);
assert_eq!(
announcement.clone,
vec![Url::parse("https://example.com/repo.git").unwrap()]
);
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
);
assert_eq!(
announcement.euc.as_deref(),
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
);
assert_eq!(
announcement.maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
}
#[test]
fn drops_malformed_values() {
let event = announcement_event(&[
&["d", "my-repo"],
&["clone", "not a url"],
&["relays", "wss://good.example.com"],
&["maintainers", "not-a-pubkey"],
]);
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_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
);
assert!(announcement.maintainers.is_empty());
}
#[test]
fn parses_upstream_tag() {
let event = announcement_event(&[
&["d", "my-fork"],
&[
"u",
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
"wss://relay.example.com",
],
]);
let announcement = Announcement::from_event(&event).expect("parses");
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!(
upstream.addr,
Some(crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream"
))
);
assert_eq!(
upstream.raw,
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
);
assert_eq!(
upstream.display().to_string(),
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
);
}
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
// The base repository, announced by the `u` tag's owner.
let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
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));
}
#[test]
fn is_fork_of_matches_a_shared_euc() {
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 = Announcement::from_event(&base_event).expect("parses");
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 = Announcement::from_event(&fork_event).expect("parses");
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(&[
&["d", "other"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let other = Announcement::from_event(&other_event).expect("parses");
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));
}
#[test]
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
// A permanent fork re-announces its EUC, the first commit after the fork.
// Only the `u` tag still relates it to the base.
let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let event = announcement_event(&[
&["d", "my-fork"],
&["u", &base.to_string()],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let fork = Announcement::from_event(&event).expect("parses");
assert!(fork.is_fork_of(&base, Some(base_euc)));
}
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
let announcement = Announcement::from_event(&event).expect("parses");
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!(maintainers.contains(&announcement.owner));
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
}
#[test]
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
&["maintainers", MAINTAINER_HEX],
]);
let announcement = Announcement::from_event(&event).expect("parses");
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_eq!(
maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
}
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
#[test]
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 second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let pr = pr_event("description", vec![Tag::event(root.id)]);
assert_eq!(
pull_request_patch(&pr, [&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
pull_request_patches(&pr, [&root, &second]),
vec![&root, &second]
);
}
#[test]
fn pull_request_patches_walks_the_reply_chain_in_order() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = pull_request_patches(&pr, [&third, &root, &second]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two", "patch-three"]
);
}
#[test]
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 tip = "1111111111111111111111111111111111111111";
let last = patch_event(
"patch-two",
vec![
Tag::event(root.id),
Tag::parse(["r", tip]).expect("valid tag"),
],
200,
);
let pr = pr_event(
"description",
vec![Tag::parse(["c", tip]).expect("valid tag")],
);
let series = pull_request_patches(&pr, [&root, &last]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two"]
);
}
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
fn pr_root() -> Event {
signed_at(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
// An update revising a different PR must be ignored even though it is newer.
let unrelated = signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
// The tip of a PR is only mutable by its author.
// A newer update from anyone else must not win.
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
}
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
vec![
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
.expect("parses"),
]
}
#[test]
fn fork_candidates_orders_own_forks_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let clone = "https://grasp.example/npub1x/my-fork.git";
let base_addr = crate::repo_addr(
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
"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![
owned_announcements(
2,
&[
&["d", "other-project"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
1,
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "their-fork"],
&["u", &base_addr.to_string()],
&["clone", clone],
],
)
.pop()
.unwrap(),
];
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
#[test]
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
let base_addr = crate::repo_addr(base_owner, "upstream");
let mut all = vec![
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "other"],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "mirror"],
&["r", euc, "euc"],
&["clone", "https://grasp.example/x/mirror.git"],
],
)
.pop()
.unwrap(),
];
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner));
assert_eq!(forks.len(), 1);
assert_eq!(forks[0].id, "mirror");
// Without a base EUC only `u`-tag forks match.
all.push(
owned_announcements(
2,
&[
&["d", "u-fork"],
&["u", &base_addr.to_string()],
&["clone", "https://grasp.example/x/u-fork.git"],
],
)
.pop()
.unwrap(),
);
let forks = fork_candidates(&all, &base_addr, None, Some(base_owner));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["u-fork"]);
}
}
+279
View File
@@ -0,0 +1,279 @@
use nostr::prelude::*;
use crate::git_event::GitEvent;
pub struct PullRequest<'a>(pub &'a Event);
impl<'a> PullRequest<'a> {
/// Wraps a pull request root event.
pub fn new(event: &'a Event) -> Self {
Self(event)
}
/// Returns the ordered patch series, found via the root reference or the tip commit.
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
let pr = self.0;
let patches: Vec<&'a Event> = patches.into_iter().collect();
if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{
return Self::forward_series(root, &patches);
}
let Some(tip) = pr.current_commit() else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| Self::patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
return Vec::new();
};
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
/// Joins the patch series with newlines, falling back to the event content.
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
let pr = self.0;
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = self.patches(patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
series
.iter()
.map(|patch| patch.content.as_str())
.collect::<Vec<_>>()
.join("\n")
}
/// Returns the root author's newest update, since only the author may move the tip.
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)
}
/// Walks reply links outward from the root patch, taking the newest child first.
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
}
/// Checks the commit against the patch's `c` and `r` tags.
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,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Returns the test signer keys.
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Builds a signed pull request event.
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
/// Builds a signed patch event with a fixed timestamp.
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
/// Joins the full patch series into one diff.
#[test]
fn pull_request_patch_joins_the_whole_patch_set() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let pr = pr_event("description", vec![Tag::event(root.id)]);
assert_eq!(
PullRequest::new(&pr).patch([&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
PullRequest::new(&pr).patches([&root, &second]),
vec![&root, &second]
);
}
/// Orders patches by walking the reply chain.
#[test]
fn pull_request_patches_walks_the_reply_chain_in_order() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two", "patch-three"]
);
}
/// Finds the patch series through the declared tip commit.
#[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111";
let last = patch_event(
"patch-two",
vec![
Tag::event(root.id),
Tag::parse(["r", tip]).expect("valid tag"),
],
200,
);
let pr = pr_event(
"description",
vec![Tag::parse(["c", tip]).expect("valid tag")],
);
let series = PullRequest::new(&pr).patches([&root, &last]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two"]
);
}
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
/// Builds a signed event with a fixed timestamp.
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
/// Builds the pull request root used by the update tests.
fn pr_root() -> Event {
signed_at(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
/// Picks the newest update targeting the root.
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
let unrelated = signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
/// Ignores updates from authors other than the root owner.
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
}
}
+44 -41
View File
@@ -1,44 +1,47 @@
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 {
/// pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
/// `refs` are `(refname, commit-id)` pairs. let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
/// `head` is the branch pointed to by the `HEAD` tag, if any.
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
let mut refs = Vec::new();
let mut head = None;
for tag in event.tags.iter() { for (name, commit) in refs {
match Nip34Tag::parse(tag.as_slice()) { tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
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 +79,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 +93,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"));
} }
} }
+21 -38
View File
@@ -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,29 @@ impl RepoStatus {
Self::Draft => Kind::GitStatusDraft, Self::Draft => Kind::GitStatusDraft,
} }
} }
}
/// NIP-10 and NIP-34 use the lowercase `e` tag. pub fn resolve<'a, I>(
/// status_events: I,
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root. root_author: &PublicKey,
pub fn references_root(event: &Event, root: &EventId) -> bool { maintainers: &[PublicKey],
let root = root.to_hex(); ) -> Self
event where
.tags I: IntoIterator<Item = &'a Event>,
.iter() {
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) status_events
} .into_iter()
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
/// Resolve the status of a root event per NIP-34. .filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
/// .max_by_key(|e| e.created_at)
/// Defaults to [`RepoStatus::Open`]. .and_then(|e| RepoStatus::from_kind(e.kind))
pub fn resolve_status<'a, I>( .unwrap_or(RepoStatus::Open)
status_events: I, }
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 +80,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 +98,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 +123,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()]
+33
View File
@@ -0,0 +1,33 @@
use nostr::prelude::*;
use crate::RepoAddr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
pub raw: String,
pub addr: Option<RepoAddr>,
}
impl Upstream {
/// Parses a raw `u` tag value, extracting the coordinate when present.
pub(crate) fn parse(raw: &str) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
.map(RepoAddr::from);
Self {
raw: raw.to_owned(),
addr,
}
}
/// Shows the coordinate when parsed, otherwise the raw value.
pub fn display(&self) -> String {
match &self.addr {
Some(addr) => addr.to_string(),
None => self.raw.clone(),
}
}
}
+4 -3
View File
@@ -10,10 +10,11 @@ signed_core = { path = "../signed_core" }
nostr.workspace = true nostr.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
gix = { workspace = true, features = ["revision", "blob-diff"] } log.workspace = true
gix-worktree = "0.56"
gix-worktree-state = "0.34"
anyhow.workspace = true anyhow.workspace = true
gix = { workspace = true, features = ["revision", "blob-diff"] }
gix-worktree = "0.57"
gix-worktree-state = "0.35"
diffy = "0.5" diffy = "0.5"
ignore = "0.4" ignore = "0.4"
+43 -45
View File
@@ -3,49 +3,49 @@ 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,
} }
impl GitCache { impl GitCache {
/// Creates a cache rooted at `root`.
pub fn new(root: PathBuf) -> Self { pub fn new(root: PathBuf) -> Self {
Self { root } Self { root }
} }
/// Returns the cache root directory.
pub fn root(&self) -> &Path { pub fn root(&self) -> &Path {
&self.root &self.root
} }
/// Returns the cache path for a repository address.
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>> { /// Opens the cached repository, or `None` when it is not cloned yet.
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::new(repo))),
Err(gix::open::Error::NotARepository { .. }) => Ok(None), Err(e) if e.is_not_found() => 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. /// Returns the cached repository, cloning from one of `clone_urls` on first use.
pub fn ensure_clone<U: AsRef<str>>( pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
&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(); if let Err(error) = repo.fetch() {
log::warn!("failed to refresh the cached repository: {error:#}");
}
return Ok(repo); return Ok(repo);
} }
@@ -54,37 +54,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 /// Maps untrusted relay content onto 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();
/// 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) }
)
/// Returns the `owner/identifier` namespace used for fork checkouts.
pub fn fork_namespace(announcement: &Announcement) -> String {
format!(
"{}/{}",
announcement.owner.to_hex(),
Self::sanitize_path_component(&announcement.id)
)
}
} }
+153 -211
View File
@@ -1,11 +1,7 @@
use std::path::Path;
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};
#[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,26 +10,51 @@ 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 @@`. impl DiffLine {
/// Creates a diff line with its kind, optional line numbers, and text.
pub fn new(kind: DiffLineKind, old: Option<u32>, new: Option<u32>, text: String) -> Self {
Self {
kind,
old,
new,
text,
}
}
}
#[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>,
} }
impl DiffHunk {
/// Creates a hunk spanning the given old and new ranges.
pub fn new(
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
lines: Vec<DiffLine>,
) -> Self {
Self {
old_start,
old_lines,
new_start,
new_lines,
lines,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus { pub enum DiffStatus {
Added, Added,
@@ -45,251 +66,172 @@ 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, 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.
pub binary: bool, pub binary: bool,
pub hunks: Vec<DiffHunk>, pub hunks: Vec<DiffHunk>,
} }
impl FileDiff {
/// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks.
pub fn new(
path: String,
old_path: Option<String>,
status: DiffStatus,
insertions: usize,
deletions: usize,
binary: bool,
hunks: Vec<DiffHunk>,
) -> Self {
Self {
path,
old_path,
status,
insertions,
deletions,
binary,
hunks,
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CommitDiff { 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 CommitDiff {
/// /// Creates a commit diff from its files.
/// Compared against its first parent, the empty tree for the root commit. pub fn new(files: Vec<FileDiff>) -> Self {
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> { Self { files }
commit_diff(&gix::open(workdir)?, id) }
} }
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> { pub(crate) struct HunkBuilder {
let commit_id = repo.rev_parse_single(id.as_bytes())?; old_start: u32,
let commit = commit_id.object()?.into_commit(); old_lines: u32,
let new_tree = commit.tree()?; new_start: u32,
let old_tree = match commit.parent_ids().next() { new_lines: u32,
Some(parent) => Some(parent.object()?.into_commit().tree()?), old: u32,
None => None, new: u32,
}; lines: Vec<DiffLine>,
tree_diff(repo, old_tree.as_ref(), &new_tree) insertions: usize,
deletions: usize,
} }
/// The changes between two commits, `base`..`tip`, like `git diff base tip`. impl HunkBuilder {
/// /// Starts a hunk spanning the given old and new ranges.
/// Directories and submodules are skipped, files are sorted by path. pub(crate) fn new(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32) -> Self {
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> { Self {
let repo = gix::open(workdir)?; old_start,
let base_tree = repo old_lines,
.rev_parse_single(base.as_bytes())? new_start,
.object()? new_lines,
.into_commit() old: old_start,
.tree()?; new: new_start,
let tip_tree = repo lines: Vec::new(),
.rev_parse_single(tip.as_bytes())? insertions: 0,
.object()? deletions: 0,
.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,
};
// 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)); /// Appends a line, numbering it and counting insertions and deletions.
pub(crate) fn push(&mut self, kind: DiffLineKind, text: impl Into<String>) {
let (old_no, new_no) = match kind {
DiffLineKind::Context => {
let numbers = (Some(self.old), Some(self.new));
self.old += 1;
self.new += 1;
numbers
}
DiffLineKind::Addition => {
self.insertions += 1;
let number = Some(self.new);
self.new += 1;
(None, number)
}
DiffLineKind::Deletion => {
self.deletions += 1;
let number = Some(self.old);
self.old += 1;
(number, None)
}
};
Ok(CommitDiff { files }) self.lines
.push(DiffLine::new(kind, old_no, new_no, text.into()));
}
/// Finishes the hunk and returns it with its insertion and deletion counts.
pub(crate) fn finish(self) -> (DiffHunk, usize, usize) {
let hunk = DiffHunk::new(
self.old_start,
self.old_lines,
self.new_start,
self.new_lines,
self.lines,
);
(hunk, self.insertions, self.deletions)
}
} }
/// Collects the hunks of one blob diff while tracking per-line numbers. pub(crate) struct HunkCollector<'a> {
struct HunkCollector<'a> {
hunks: &'a mut Vec<DiffHunk>, hunks: &'a mut Vec<DiffHunk>,
insertions: &'a mut usize, insertions: &'a mut usize,
deletions: &'a mut usize, deletions: &'a mut usize,
} }
impl<'a> HunkCollector<'a> {
/// Collects hunks into `hunks` while counting `insertions` and `deletions`.
pub(crate) fn new(
hunks: &'a mut Vec<DiffHunk>,
insertions: &'a mut usize,
deletions: &'a mut usize,
) -> Self {
Self {
hunks,
insertions,
deletions,
}
}
}
impl ConsumeHunk for HunkCollector<'_> { impl ConsumeHunk for HunkCollector<'_> {
type Out = (); type Out = ();
/// Consumes one hunk, tracking line numbers and insertion/deletion counts.
fn consume_hunk( fn consume_hunk(
&mut self, &mut self,
header: HunkHeader, header: HunkHeader,
lines: &[(GixLineKind, &[u8])], lines: &[(GixLineKind, &[u8])],
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let mut old_ln = header.before_hunk_start; let mut builder = HunkBuilder::new(
let mut new_ln = header.after_hunk_start; header.before_hunk_start,
let mut out = Vec::with_capacity(lines.len()); header.before_hunk_len,
header.after_hunk_start,
header.after_hunk_len,
);
for (kind, content) in lines { for (kind, content) in lines {
let text = String::from_utf8_lossy(content).into_owned(); let kind = match kind {
let line = match kind { GixLineKind::Context => DiffLineKind::Context,
GixLineKind::Context => { GixLineKind::Remove => DiffLineKind::Deletion,
let line = DiffLine { GixLineKind::Add => DiffLineKind::Addition,
kind: DiffLineKind::Context,
old: Some(old_ln),
new: Some(new_ln),
text,
};
old_ln += 1;
new_ln += 1;
line
}
GixLineKind::Remove => {
*self.deletions += 1;
let line = DiffLine {
kind: DiffLineKind::Deletion,
old: Some(old_ln),
new: None,
text,
};
old_ln += 1;
line
}
GixLineKind::Add => {
*self.insertions += 1;
let line = DiffLine {
kind: DiffLineKind::Addition,
old: None,
new: Some(new_ln),
text,
};
new_ln += 1;
line
}
}; };
out.push(line); builder.push(kind, String::from_utf8_lossy(content).into_owned());
} }
self.hunks.push(DiffHunk { let (hunk, insertions, deletions) = builder.finish();
old_start: header.before_hunk_start, *self.insertions += insertions;
old_lines: header.before_hunk_len, *self.deletions += deletions;
new_start: header.after_hunk_start, self.hunks.push(hunk);
new_lines: header.after_hunk_len,
lines: out,
});
Ok(()) Ok(())
} }
/// Marks the end of the unified diff stream.
fn finish(self) {} fn finish(self) {}
} }
+59 -230
View File
@@ -1,248 +1,77 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::Result;
/// In-memory object cache for history walks, see [`open_with_cache`].
///
/// 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)]
pub struct FileCommit {
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
pub id: String,
/// First line of the commit message.
pub summary: String,
/// Rest of the commit message after the title.
///
/// `None` for single-line commit messages.
pub description: Option<String>,
pub author: String,
/// Author time, seconds since the Unix epoch.
pub time: i64,
}
/// Open the repository at `workdir` with an in-memory object cache.
///
/// Only history walks use it, they re-decode the same commit objects repeatedly.
/// 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
.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,
};
// Compare each unresolved path against this commit and its first parent.
let mut ix = 0;
while ix < pending.len() {
let rel = &pending[ix];
let blob = tree.lookup_entry_by_path(rel)?;
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(), file_commit(&commit)?));
pending.swap_remove(ix);
} else {
ix += 1;
}
}
}
Ok(found)
}
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
/// the tab badge shows the real count.
///
/// 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. impl CommitList {
/// /// Creates a list holding `total` commits of which `commits` are materialized.
/// Returns an empty list for a repository without any commits yet. pub fn new(total: usize, commits: Vec<FileCommit>) -> Self {
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> { Self { total, commits }
use gix::traverse::commit::simple::CommitTimeOrder; }
}
let Some(head) = repo.head_id().ok() else { #[derive(Debug, Clone)]
return Ok(CommitList { pub struct FileCommit {
total: 0, pub id: String,
commits: Vec::new(), pub summary: String,
}); pub description: Option<String>,
}; pub author: String,
pub time: i64,
}
let walk = repo impl FileCommit {
.rev_walk([head]) /// Creates a commit entry from its id, message parts, author, and timestamp.
.sorting(gix::revision::walk::Sorting::ByCommitTime( pub fn new(
CommitTimeOrder::NewestFirst, id: String,
)); summary: String,
description: Option<String>,
let mut commits = Vec::new(); author: String,
let mut total = 0; time: i64,
) -> Self {
for info in walk.all()? { Self {
let info = info?; id,
total += 1; summary,
if commits.len() < MAX_LISTED_COMMITS { description,
commits.push(file_commit_summary(&info.object()?)?); author,
time,
} }
} }
Ok(CommitList { total, commits }) /// Converts a gix commit including its message body.
} pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
Self::from_commit_with_description(commit, true)
}
/// For non-bare clones the clone root is the worktree. /// Converts a gix commit, skipping the message body since lists never show it.
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> { pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
all_commits(&open_with_cache(workdir)?) Self::from_commit_with_description(commit, false)
} }
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. /// Converts a gix commit, optionally skipping the message body.
pub fn worktree_commit_range_commits( fn from_commit_with_description(
workdir: &Path, commit: &gix::Commit<'_>,
base: &str, include_description: bool,
tip: &str, ) -> anyhow::Result<Self> {
) -> Result<Vec<FileCommit>> { use crate::GixResultExt as _;
use gix::traverse::commit::simple::CommitTimeOrder;
let repo = open_with_cache(workdir)?; let author = commit.author().into_anyhow()?;
let base_id = repo.rev_parse_single(base.as_bytes())?; let message = commit.message().into_anyhow()?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
let walk = repo Ok(FileCommit::new(
.rev_walk([tip_id]) commit.id().shorten_or_id().to_string(),
.sorting(gix::revision::walk::Sorting::ByCommitTime( String::from_utf8_lossy(message.title).trim().to_string(),
CommitTimeOrder::NewestFirst, if include_description {
message
.body
.map(|body| String::from_utf8_lossy(body).trim().to_string())
.filter(|body| !body.is_empty())
} else {
None
},
String::from_utf8_lossy(author.name).trim().to_string(),
author.time().into_anyhow()?.seconds,
)) ))
.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),
} }
} }
+24 -32
View File
@@ -3,49 +3,41 @@ mod diff;
mod history; mod history;
mod nip34; mod nip34;
mod patch; mod patch;
mod remote;
mod repo; mod repo;
mod scan; mod scan;
mod sync;
mod worktree; 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 sync::{RefSync, RepoSyncStatus};
WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag, pub use worktree::WorktreeSnapshot;
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
}; pub(crate) trait GixResultExt<T> {
/// Converts a gix exception result into an `anyhow` result.
fn into_anyhow(self) -> anyhow::Result<T>;
}
impl<T, E> GixResultExt<T> for Result<T, gix::Exn<E>>
where
E: std::error::Error + Send + Sync + 'static,
{
fn into_anyhow(self) -> anyhow::Result<T> {
self.map_err(|exn| anyhow::Error::from(exn.into_error()))
}
}
/// 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!(
+22 -413
View File
@@ -1,17 +1,15 @@
use std::path::Path;
use anyhow::Result;
use gix::bstr::ByteSlice;
use nostr::prelude::*; use nostr::prelude::*;
/// The kind of NIP-34 relationship a local repository has on disk. #[derive(serde::Deserialize)]
pub(crate) struct Nip34Json {
pub(crate) identifier: Option<String>,
pub(crate) owner: Option<String>,
}
#[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`.
Initialized, Initialized,
/// Cloned from a `nostr://` remote but never initialized locally.
Cloned, Cloned,
/// Nostr tooling touched the repository but no binding is recoverable.
ToolingOnly, ToolingOnly,
} }
@@ -22,7 +20,6 @@ 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.
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,
@@ -31,424 +28,36 @@ pub struct GraspSignals {
} }
impl GraspSignals { impl GraspSignals {
/// Returns whether any Nostr tooling marker was found.
pub fn any(&self) -> bool { pub fn any(&self) -> bool {
*self != Self::default() *self != Self::default()
} }
} }
/// What a local repository's on-disk state says about its NIP-34 binding.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
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`.
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>,
} }
#[derive(serde::Deserialize)] impl Nip34Binding {
struct Nip34Json { /// Creates a binding from its kind, detected signals, and recovered coordinates.
identifier: Option<String>, pub fn new(
owner: Option<String>, kind: Nip34Kind,
} signals: GraspSignals,
owner: Option<PublicKey>,
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding> { identifier: Option<String>,
let repo = gix::open(repo_path).ok()?; grasp_urls: Vec<String>,
let common_dir = repo.common_dir().to_path_buf(); ) -> Self {
let workdir = repo.workdir().map(Path::to_path_buf); Self {
kind,
let mut signals = GraspSignals::default(); signals,
let mut owner: Option<PublicKey> = None; owner,
let mut identifier: Option<String> = None; identifier,
let mut grasp_urls: Vec<String> = Vec::new(); grasp_urls,
if let Some(workdir) = &workdir {
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
{
signals.nip34_json = true;
identifier = config.identifier.and_then(non_empty);
owner = config
.owner
.as_deref()
.and_then(|value| PublicKey::parse(value).ok());
}
if workdir.join("maintainers.yaml").is_file() {
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") {
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)) = parse_nostr_url(&url)
{
owner = Some(key);
identifier = Some(id);
}
}
if is_grasp_url(&url) {
signals.grasp_remote = true;
signals.nip34_grasp_remote |= nak_grasp_remote;
grasp_urls.push(url.to_string());
if owner.is_none()
&& identifier.is_none()
&& let Some((key, id)) = grasp_parts(&url)
{
owner = Some(key);
identifier = Some(id);
}
}
}
}
}
}
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
if let Ok(platform) = repo.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,
})
}
/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config.
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();
if path.matches('/').count() != 2 || path.len() < 65 {
return false;
}
grasp_parts(url).is_some()
}
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
let parsed = Url::parse(url).ok()?;
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
let owner = PublicKey::parse(segments.next()?).ok()?;
let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?;
Some((owner, identifier))
}
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
if coordinate.kind != Kind::GitRepoAnnouncement {
return None;
}
let identifier = non_empty(coordinate.identifier.clone())?;
Some((coordinate.public_key, identifier))
}
/// 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)]
mod tests {
use std::process::Command;
use super::*;
fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
std::fs::create_dir_all(&path).expect("mkdir");
git(&path, &["init", "-q"]);
(dir, path)
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn key() -> PublicKey {
Keys::generate().public_key()
}
fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String {
let coordinate = Coordinate::new(kind, owner).identifier(identifier);
Nip19Coordinate::new(coordinate, Vec::<RelayUrl>::new())
.to_bech32()
.expect("naddr")
}
#[test]
fn plain_repository_has_no_binding() {
let (_dir, path) = init_repo();
assert!(detect_nip34(&path).is_none());
}
#[test]
fn nip34_json_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
std::fs::write(
path.join("nip34.json"),
format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#),
)
.expect("write");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_json);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn malformed_nip34_json_is_ignored() {
let (_dir, path) = init_repo();
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
assert!(detect_nip34(&path).is_none());
}
#[test]
fn nak_exclude_and_state_refs_are_detected() {
let (_dir, path) = init_repo();
std::fs::create_dir_all(path.join(".git/info")).expect("mkdir");
std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write");
git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]);
git(
&path,
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_excluded);
assert!(binding.signals.nip34_state_refs);
}
#[test]
fn nostr_repo_config_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
git(&path, &["config", "nostr.repo", &naddr]);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nostr_repo_config);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn the_written_nostr_repo_marker_is_detected() {
let (_dir, path) = init_repo();
let owner = key();
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
set_nostr_repo(&path, &naddr).expect("write marker");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nostr_repo_config);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nostr_remote_is_a_nip34_clone() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
git(&path, &["remote", "add", "origin", &url]);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Cloned);
assert!(binding.signals.nostr_remote);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nak_grasp_remote_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
let url = format!("https://gitnostr.com/{npub}/my-repo.git");
git(
&path,
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_grasp_remote);
assert!(binding.signals.grasp_remote);
assert_eq!(binding.grasp_urls, vec![url]);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nostr_cache_alone_is_tooling_only() {
let (_dir, path) = init_repo();
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
assert!(binding.signals.nostr_cache);
}
#[test]
fn grasp_urls_are_recognised_by_shape() {
let owner = key();
let npub = owner.to_bech32().expect("npub");
assert!(is_grasp_url(&format!(
"https://gitnostr.com/{npub}/my-repo.git"
)));
assert!(is_grasp_url(&format!(
"grasp://gitnostr.com/{npub}/my-repo.git"
)));
assert!(!is_grasp_url("https://gitnostr.com/my-repo.git"));
assert!(!is_grasp_url(
"https://gitnostr.com/not-a-pubkey/my-repo.git"
));
assert!(!is_grasp_url(&format!(
"ssh://gitnostr.com/{npub}/my-repo.git"
)));
}
} }
+218 -302
View File
@@ -1,322 +1,238 @@
use std::io::Write; use anyhow::Result;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
use diffy::{Hunk, Line}; use diffy::{Hunk, Line};
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; use crate::diff::{CommitDiff, DiffHunk, DiffLineKind, DiffStatus, FileDiff, HunkBuilder};
use crate::history::FileCommit; use crate::history::FileCommit;
/// 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 /// Splits an mbox series into the raw text of each message.
.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(()) /// Parses a git diff into file diffs, returning an empty diff without one.
} pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
return Ok(CommitDiff::new(Vec::new()));
}
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. let mut files = Vec::new();
/// Fails when the range has no commits.
///
/// 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() { for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
bail!( files.push(Self::file_diff(file?));
"git format-patch failed: {}", }
String::from_utf8_lossy(&output.stderr).trim()
Ok(CommitDiff::new(files))
}
/// Lists the commits represented by the mbox messages that carry an id.
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
Self::envelopes(patch)
.into_iter()
.filter(|message| !message.id.is_empty())
.map(|message| {
FileCommit::new(
message.id.to_string(),
message
.header("Subject")
.map(Self::strip_patch_prefix)
.unwrap_or_default(),
None,
message
.header("From")
.map(Self::name_from_address)
.unwrap_or_default(),
message
.header("Date")
.and_then(|value| gix::date::parse(value.trim(), None).ok())
.map(|time| time.seconds)
.unwrap_or(0),
)
})
.collect()
}
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
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::new(&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::new(&patch[start..], id, headers));
}
if messages.is_empty() {
messages.push(Envelope::new(patch, "", Vec::new()));
}
messages
}
/// Extracts the display name from a `From` header value.
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
None => from.trim().to_string(),
}
}
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
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()
}
}
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
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, hunk_insertions, hunk_deletions) = Self::hunk_diff(hunk);
insertions += hunk_insertions;
deletions += hunk_deletions;
hunks.push(hunk);
}
}
FileDiff::new(
path.to_owned(),
old_path.map(str::to_owned),
status,
insertions,
deletions,
patch.is_binary(),
hunks,
)
}
/// Converts a diffy hunk into a `DiffHunk` with its insertion and deletion counts.
fn hunk_diff(hunk: &Hunk<'_, str>) -> (DiffHunk, usize, usize) {
let old_range = hunk.old_range();
let new_range = hunk.new_range();
let mut builder = HunkBuilder::new(
old_range.start() as u32,
old_range.len() as u32,
new_range.start() as u32,
new_range.len() as u32,
); );
}
let patch = String::from_utf8_lossy(&output.stdout).into_owned(); 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),
};
if patch.trim().is_empty() { let text = text.strip_suffix('\n').unwrap_or(text);
bail!("no commits between {base} and {tip}"); let text = text.strip_suffix('\r').unwrap_or(text);
} builder.push(kind, text.to_owned());
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;
}
starts
.iter()
.enumerate()
.map(|(i, &start)| {
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
&patch[start..end]
})
.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();
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
files.push(file_diff(file?)?);
}
Ok(CommitDiff { files })
}
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
// component is 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 = 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,
})
}
/// 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.
///
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
fn line_text(text: &str) -> String {
let text = text.strip_suffix('\n').unwrap_or(text);
text.strip_suffix('\r').unwrap_or(text).to_owned()
}
/// Commits of a `git format-patch` output, a single patch or a series.
///
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
let lines: Vec<&str> = patch.lines().collect();
let mut commits = Vec::new();
let mut i = 0;
while i < lines.len() {
// A patch starts with its `From <id> <date>` envelope line.
let Some(rest) = lines[i].strip_prefix("From ") else {
i += 1;
continue;
};
let Some(id) = rest.split_whitespace().next() else {
i += 1;
continue;
};
if id.len() != 40 {
i += 1;
continue;
} }
let mut author = String::new(); builder.finish()
let mut summary = String::new();
let mut time = 0i64;
// Envelope headers run up to the blank line before the commit message.
i += 1;
while i < lines.len() && !lines[i].is_empty() {
let header = lines[i];
if let Some(value) = header.strip_prefix("From: ") {
author = name_from_address(value);
} else if let Some(value) = header.strip_prefix("Subject: ") {
summary = strip_patch_prefix(value);
} else if let Some(value) = header.strip_prefix("Date: ") {
time = gix::date::parse(value.trim(), None)
.map(|t| t.seconds)
.unwrap_or(0);
}
i += 1;
}
commits.push(FileCommit {
id: id.to_string(),
summary,
description: None,
author,
time,
});
}
commits
}
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
None => from.trim().to_string(),
} }
} }
/// Strip the patch prefix from a `Subject:` header. struct Envelope<'a> {
/// text: &'a str,
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. id: &'a str,
fn strip_patch_prefix(subject: &str) -> String { headers: Vec<&'a str>,
let trimmed = subject.trim(); }
let Some(rest) = trimmed.strip_prefix('[') else {
return trimmed.to_string(); impl<'a> Envelope<'a> {
}; /// Creates an envelope from its text, commit id, and header lines.
let Some(end) = rest.find(']') else { fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
return trimmed.to_string(); Self { text, id, headers }
}; }
if rest[..end].to_ascii_lowercase().contains("patch") {
rest[end + 1..].trim().to_string() /// Returns the first header line matching `name: `.
} else { fn header(&self, name: &str) -> Option<&str> {
trimmed.to_string() let prefix = format!("{name}: ");
self.headers
.iter()
.find(|line| line.starts_with(&prefix))
.map(|line| &line[prefix.len()..])
} }
} }
-351
View File
@@ -1,351 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use gix::interrupt::IS_INTERRUPTED;
use gix::progress::Discard;
/// Clone into `path` from the first working URL in `clone_urls`.
///
/// 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| {
let repo = clone(url, path)?;
// 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 {
extra_refspecs: vec![refspec.clone()],
..Default::default()
};
repo.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(())
})
}
/// The URL of the `origin` remote of the repository at `workdir`.
///
/// `None` when it has no `origin` yet.
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
let Ok(repo) = gix::open(workdir) else {
return Ok(None);
};
let Ok(remote) = repo.find_remote("origin") else {
return Ok(None);
};
Ok(remote
.url(gix::remote::Direction::Fetch)
.map(|url| url.to_string()))
}
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
///
/// `what` names the command in the spawn error.
pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.with_context(|| format!("failed to spawn `{what}`"))
}
File diff suppressed because it is too large Load Diff
+55 -29
View File
@@ -1,56 +1,82 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
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.
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. impl LocalRepo {
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> { /// Creates a local repository entry from its path and optional NIP-34 binding.
if !root.is_dir() { pub fn new(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
return Vec::new(); Self { path, nip34 }
} }
}
let walker = WalkBuilder::new(root) /// Finds git repositories under `root` up to `SCAN_MAX_DEPTH` deep, without descending into them.
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
let Ok(root) = root.canonicalize() else {
return Vec::new();
};
let found = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
let walker = WalkBuilder::new(&root)
.max_depth(Some(SCAN_MAX_DEPTH)) .max_depth(Some(SCAN_MAX_DEPTH))
// Honour `.gitignore` even when the scan root is not itself a repository.
.require_git(false) .require_git(false)
.filter_entry({
let found = found.clone();
move |entry| {
let Some(kind) = entry.file_type() else {
return true;
};
if !kind.is_dir() {
return true;
}
let dir = entry.path();
let Ok(repo) = gix::discover(dir) else {
return true;
};
let Some(workdir) = repo.workdir() else {
return false;
};
if workdir != dir {
return true;
}
match found.lock() {
Ok(mut repos) => repos.push(dir.to_path_buf()),
Err(poisoned) => poisoned.into_inner().push(dir.to_path_buf()),
}
false
}
})
.build(); .build();
let mut repos: Vec<PathBuf> = walker for _ in walker {}
.flatten()
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir()))
.map(ignore::DirEntry::into_path)
.filter(|dir| dir.join(".git").exists())
.filter_map(|dir| dir.canonicalize().ok())
.collect();
let mut repos = match found.lock() {
Ok(mut repos) => std::mem::take(&mut *repos),
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
};
repos.sort(); repos.sort();
repos.dedup();
// A repository nested inside another, like a submodule worktree, is not reported. repos
let mut roots: Vec<PathBuf> = Vec::with_capacity(repos.len());
for repo in repos {
if !roots.iter().any(|kept| repo.starts_with(kept)) {
roots.push(repo);
}
}
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::new(path, nip34)
}) })
.collect() .collect()
} }
+38
View File
@@ -0,0 +1,38 @@
use std::collections::BTreeMap;
/// How a single branch compares between local and Nostr.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefSync {
InSync,
LocalAhead { ahead: usize },
RemoteAhead { behind: usize },
Diverged { ahead: usize, behind: usize },
LocalOnly,
RemoteOnly,
}
/// Branch comparison between a local repository and its Nostr state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoSyncStatus {
/// Branch short name and its classification.
pub refs: Vec<(String, RefSync)>,
pub ahead_total: usize,
pub behind_total: usize,
}
impl RepoSyncStatus {
/// True when every branch matches the Nostr state.
pub fn in_sync(&self) -> bool {
self.refs.iter().all(|(_, sync)| *sync == RefSync::InSync)
}
/// Maps branch short name to lowercase commit oid.
pub(crate) fn branches(refs: &[(String, String)]) -> BTreeMap<&str, String> {
refs.iter()
.filter_map(|(name, commit)| {
name.strip_prefix("refs/heads/")
.map(|branch| (branch, commit.to_ascii_lowercase()))
})
.collect()
}
}
File diff suppressed because it is too large Load Diff
+23 -321
View File
@@ -1,334 +1,36 @@
use std::collections::HashSet; use std::path::PathBuf;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result}; use crate::history::FileCommit;
use gix::progress::Discard;
use crate::history::{FileCommit, head_commit};
use crate::repo::{current_branch, repository_signature};
/// Whether the worktree of `workdir` has uncommitted changes.
///
/// Best-effort: any read failure is reported as clean.
pub fn worktree_dirty(workdir: &Path) -> bool {
let Ok(repo) = gix::open(workdir) else {
return 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,
}
}
false
}
/// Commits in `base..branch` of the checkout at `workdir`.
///
/// Best-effort: 0 when the range cannot be computed.
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))
else {
return 0;
};
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 { pub struct WorktreeSnapshot {
/// Relative paths of all worktree entries, directories first.
pub entries: Vec<PathBuf>, pub entries: Vec<PathBuf>,
/// README path relative to the worktree, if any.
pub readme_path: Option<PathBuf>, pub readme_path: Option<PathBuf>,
/// Contents of the README, if any.
pub readme: Option<Vec<u8>>, pub readme: Option<Vec<u8>>,
/// Branch HEAD points to, `None` when detached, for example on a tag.
pub current_branch: Option<String>, pub current_branch: Option<String>,
/// Commit HEAD points to, if any, see [`head_commit`].
pub head_commit: Option<FileCommit>, pub head_commit: Option<FileCommit>,
pub branches: Vec<String>,
pub tags: Vec<String>,
} }
/// Collects entries, the README, the branch HEAD points to and its commit. impl WorktreeSnapshot {
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> { /// Creates a snapshot of the worktree contents and ref state.
let repo = gix::open(workdir)?; pub(crate) fn new(
let readme_path = find_readme(&repo)?; entries: Vec<PathBuf>,
let readme = match &readme_path { readme_path: Option<PathBuf>,
Some(path) => worktree_read(&repo, path)?, readme: Option<Vec<u8>>,
None => None, current_branch: Option<String>,
}; head_commit: Option<FileCommit>,
Ok(WorktreeSnapshot { branches: Vec<String>,
entries: worktree_entries(&repo)?, tags: Vec<String>,
readme_path, ) -> Self {
readme, Self {
current_branch: current_branch(&repo)?, entries,
head_commit: head_commit(&repo)?, readme_path,
}) readme,
} current_branch,
head_commit,
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { branches,
let workdir = repo tags,
.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);
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 =
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
options.overwrite_existing = true;
let objects = repo.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(())
}
/// 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,
new: target,
},
name: head,
deref: false,
}],
Some(signature),
)?;
Ok(())
}
/// Check out the local branch `name`, HEAD stays attached to it.
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
let repo = gix::open(workdir)?;
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 = repo.find_reference(&full)?;
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::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(())
} }
+1 -6
View File
@@ -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
+31 -35
View File
@@ -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 }
}
} }
+1 -1
View File
@@ -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;
-3
View File
@@ -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;
-2
View File
@@ -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,
} }
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
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"];
pub(crate) 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()
}
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();
let latest = events
.into_iter()
.max_by_key(|event| event.created_at)
.map(|event| grasp_list_servers(&event))
.unwrap_or_default();
Ok(latest)
}
+212 -389
View File
@@ -5,88 +5,58 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::Error; use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use nostr::prelude::*; use nostr::prelude::*;
use settings::{CheckoutRecord, SettingsStore}; use settings::{CheckoutRecord, CheckoutsSettings, 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);
/// How often a full pass refreshes the remotes while any repository panel is open.
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 MAX_STATUS_CHECKOUTS: usize = 8; const MAX_STATUS_CHECKOUTS: usize = 8;
const LOCAL_POLL: Duration = Duration::from_secs(2);
const STATUS_POLL: Duration = Duration::from_secs(15);
const PUSH_POLL: Duration = Duration::from_secs(60);
struct GlobalCheckoutsStore(Entity<CheckoutsStore>); 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.
last_full_sync: Option<Instant>, last_full_sync: Option<Instant>,
checkouts_settings: CheckoutsSettings,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -101,6 +71,7 @@ impl CheckoutsStore {
pub fn new(cx: &mut Context<Self>) -> Self { pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new(); let mut subscriptions = Vec::new();
let mut checkouts_settings = CheckoutsSettings::default();
if !cfg!(target_arch = "wasm32") { if !cfg!(target_arch = "wasm32") {
let settings = SettingsStore::global(cx); let settings = SettingsStore::global(cx);
@@ -108,7 +79,15 @@ impl CheckoutsStore {
let repos = RepoListStore::global(cx); let repos = RepoListStore::global(cx);
let backend = Backend::global(cx); let backend = Backend::global(cx);
subscriptions.push(cx.observe(&settings, |this, _settings, cx| { checkouts_settings = settings.read(cx).settings().checkouts.clone();
subscriptions.push(cx.observe(&settings, |this, settings, cx| {
let checkouts = settings.read(cx).settings().checkouts.clone();
if this.checkouts_settings == checkouts {
return;
}
// Only edits to the checkouts section affect the derived state
this.checkouts_settings = checkouts;
this.refresh(cx); this.refresh(cx);
})); }));
@@ -155,6 +134,7 @@ impl CheckoutsStore {
debounce_pending: false, debounce_pending: false,
local_pending: false, local_pending: false,
last_full_sync: None, last_full_sync: None,
checkouts_settings,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -187,17 +167,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 +184,15 @@ 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.
///
/// Its ready-to-push status is obsolete. Drop it from the cached statuses
/// 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;
@@ -248,14 +210,9 @@ impl CheckoutsStore {
cx.notify(); cx.notify();
} }
// The other checkouts of this repository still need re-deriving
// against the remote, now that the pushed refs landed there.
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 +224,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;
@@ -277,12 +233,13 @@ impl CheckoutsStore {
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await; cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx)) this.update(cx, |this, cx| {
this.run_refresh(cx);
})
}) })
.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 +263,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 +280,41 @@ 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 root = signed_git::root_commit(path).ok().flatten(); let origin = Repo::open(path)
.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 +323,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 +341,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();
} }
@@ -390,12 +351,11 @@ impl CheckoutsStore {
})?; })?;
if again { if again {
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 +367,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 +383,16 @@ 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.
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 +413,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 +431,16 @@ 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).
if this.refresh.running() || this.debounce_pending { if this.refresh.running() || this.debounce_pending {
return; return;
} }
@@ -511,217 +462,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 +665,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 +673,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 +700,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 +756,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 +788,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);
} }
} }
+26 -26
View File
@@ -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
View File
@@ -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())
})
}
} }
+21 -40
View File
@@ -1,31 +1,35 @@
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;
mod sync_status;
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;
pub use sync_status::{CheckoutSyncStatus, SyncStatusStore};
#[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 +39,18 @@ 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);
} SyncStatusStore::set_global(cx.new(SyncStatusStore::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);
} }
+35 -96
View File
@@ -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"));
}
} }
+4 -17
View File
@@ -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,
+528
View File
@@ -0,0 +1,528 @@
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(s)://<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.
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);
}
}
#[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 -50
View File
@@ -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());
}
}
File diff suppressed because it is too large Load Diff
+21 -41
View File
@@ -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,23 @@ 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.
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.
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.
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.
///
/// 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 +86,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 +100,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 +128,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 +150,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 +195,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 +211,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 +223,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 +233,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 +255,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 +272,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))?;
} }
+239
View File
@@ -0,0 +1,239 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use nostr_sdk::prelude::*;
use signed_core::{Deletions, Filters, RepoAddr, RepoState};
use signed_git::{Repo, RepoSyncStatus};
use crate::backend::{Backend, BackendEvent};
use crate::checkouts::CheckoutsStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::RepoListStore;
// Nostr state changes rarely, so drift detection runs on a slow poll.
const SYNC_POLL: Duration = Duration::from_secs(60);
const MAX_SYNC_CHECKOUTS: usize = 8;
struct GlobalSyncStatusStore(Entity<SyncStatusStore>);
impl Global for GlobalSyncStatusStore {}
/// A checkout compared against the repository state published on Nostr.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutSyncStatus {
pub path: PathBuf,
pub status: RepoSyncStatus,
}
pub struct SyncStatusStore {
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
refresh: RefreshGate,
poll_pending: bool,
_subscriptions: Vec<Subscription>,
}
impl SyncStatusStore {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalSyncStatusStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalSyncStatusStore(entity));
}
pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new();
if !cfg!(target_arch = "wasm32") {
let repos = RepoListStore::global(cx);
let checkouts = CheckoutsStore::global(cx);
let backend = Backend::global(cx);
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.refresh(cx);
}));
// Checkout associations decide which paths get compared.
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
this.refresh(cx);
}));
subscriptions.push(
cx.subscribe(&backend, |this, _backend, event, cx| match event {
BackendEvent::SignerChanged => {
this.statuses.clear();
cx.notify();
this.refresh(cx);
}
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.refresh(cx),
_ => {}
}),
);
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
log::warn!("sync status store dropped before the initial refresh: {error}");
}
});
}
Self {
statuses: HashMap::new(),
refresh: RefreshGate::default(),
poll_pending: false,
_subscriptions: subscriptions,
}
}
/// Latest sync status per associated checkout.
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutSyncStatus> {
self.statuses.get(addr).cloned().unwrap_or_default()
}
/// Commits available locally but missing on Nostr.
pub fn unsynced(&self, addr: &RepoAddr) -> usize {
self.statuses
.get(addr)
.map(|list| {
list.iter()
.map(|checkout| checkout.status.ahead_total)
.sum()
})
.unwrap_or(0)
}
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
let changed = !self.statuses.is_empty();
self.statuses.clear();
if changed {
cx.notify();
}
return;
};
self.schedule_poll(cx);
self.run_refresh(user, cx);
}
fn schedule_poll(&mut self, cx: &mut Context<Self>) {
if self.poll_pending {
return;
}
self.poll_pending = true;
cx.spawn(async move |this, cx| {
cx.background_executor().timer(SYNC_POLL).await;
this.update(cx, |this, cx| {
this.poll_pending = false;
this.refresh(cx);
})
})
.detach();
}
fn run_refresh(&mut self, user: PublicKey, cx: &mut Context<Self>) {
self.refresh.begin();
let backend = Backend::global(cx);
let client = backend.read(cx).client();
let repos = RepoListStore::global(cx);
let announcements = repos.read(cx).announcements_of(&user);
let checkouts = CheckoutsStore::global(cx);
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = announcements
.iter()
.map(|announcement| {
let addr = announcement.addr();
let paths = checkouts.read(cx).associations_of(&addr);
(addr, paths)
})
.collect();
let work = cx.background_spawn(async move {
let db = client.database();
let deletion_events = db.query(Filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
let mut states: HashMap<RepoAddr, Vec<(String, String)>> = HashMap::new();
for (addr, _) in &targets {
let events = db.query(addr.state_filter()).await?;
let latest = utils::latest(
events
.into_iter()
.filter(|event| !deletions.is_deleted(event)),
);
let Some(event) = latest else {
continue;
};
states.insert(addr.clone(), RepoState::parse(&event).refs);
}
let mut statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>> = HashMap::new();
for (addr, paths) in &targets {
let Some(nostr_refs) = states.get(addr) else {
continue;
};
let mut list = Vec::new();
for path in paths.iter().take(MAX_SYNC_CHECKOUTS) {
let synced =
Repo::try_open(path).and_then(|repo| repo.sync_status(nostr_refs).ok());
if let Some(status) = synced {
list.push(CheckoutSyncStatus {
path: path.clone(),
status,
});
}
}
if !list.is_empty() {
statuses.insert(addr.clone(), list);
}
}
Ok::<_, Error>(statuses)
});
cx.spawn(async move |this, cx| {
let statuses = match work.await {
Ok(statuses) => statuses,
Err(error) => {
log::warn!("failed to compute nostr sync statuses: {error}");
return this.update(cx, |this, _cx| {
this.refresh.abort();
});
}
};
let again = this.update(cx, |this, cx| {
if this.statuses != statuses {
this.statuses = statuses;
cx.notify();
}
this.refresh.finish()
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok::<_, Error>(())
})
.detach();
}
}
-2
View File
@@ -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;
-2
View File
@@ -4,8 +4,6 @@ use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::ActiveTheme; use gpui_component::ActiveTheme;
/// A small count badge shown after a label. /// A small count badge shown after a label.
/// Used on segmented filter buttons, like `All 12`, and on tabs.
/// Rendered from theme tokens and sized for the compact header buttons it lives on.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct CountBadge { pub struct CountBadge {
count: usize, count: usize,
-3
View File
@@ -4,8 +4,6 @@ use gpui_component::searchable_list::SearchableListItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
/// A dropdown option with a display label and a stored value. /// A dropdown option with a display label and a stored value.
/// The trigger and menu render the `label`.
/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
#[derive(Clone)] #[derive(Clone)]
pub struct SelectOption { pub struct SelectOption {
value: SharedString, value: SharedString,
@@ -80,7 +78,6 @@ pub fn setting_row(
} }
/// A full-width settings block with title and subtitle in one header. /// A full-width settings block with title and subtitle in one header.
/// `gap_3` separates the header from the control below.
pub fn setting_block( pub fn setting_block(
cx: &App, cx: &App,
title: impl Into<SharedString>, title: impl Into<SharedString>,
+1 -2
View File
@@ -5,8 +5,7 @@ use gpui_component::tooltip::Tooltip;
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex}; use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
use signed_core::RepoStatus; use signed_core::RepoStatus;
/// The status badge shown next to an issue or pull request. /// Status badge shown next to an issue or pull request.
/// It has an icon and a colored square, with a tooltip describing the status.
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status { let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => ( RepoStatus::Open => (
-5
View File
@@ -9,11 +9,6 @@ struct WindowDragState {
} }
/// Make an element behave like a window title bar. /// Make an element behave like a window title bar.
/// Dragging it moves the window.
/// Double-clicking zooms the window.
/// On macOS it runs the platform's default title-bar double-click action.
/// Only the bar's non-interactive areas should get this.
/// Tabs are draggable to reorder panels and must not move the window.
pub fn title_bar_drag_handlers( pub fn title_bar_drag_handlers(
this: Stateful<Div>, this: Stateful<Div>,
window: &mut Window, window: &mut Window,
-3
View File
@@ -4,9 +4,6 @@ use gpui_component::list::ListItem;
use gpui_component::tree::TreeEntry; use gpui_component::tree::TreeEntry;
use gpui_component::{Icon, IconName, Sizable, h_flex}; use gpui_component::{Icon, IconName, Sizable, h_flex};
/// One row of a file tree, an icon and a name indented by depth.
/// Clicking a file runs `on_click`.
/// Folders expand and collapse via the tree itself.
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where where
F: Fn(&mut Window, &mut App) + 'static, F: Fn(&mut Window, &mut App) + 'static,
-38
View File
@@ -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");
}
}
+5 -1
View File
@@ -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};
+6 -41
View File
@@ -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");
}
} }
+20
View File
@@ -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.
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))
}
+21
View File
@@ -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
}
}
+15 -20
View File
@@ -1,6 +1,20 @@
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 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 +34,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;
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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),
+2 -2
View File
@@ -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();
+2
View File
@@ -3,6 +3,7 @@ mod dialog_state;
pub(crate) mod discussion; pub(crate) mod discussion;
mod inbox; mod inbox;
mod issues; mod issues;
mod placeholder_panel;
mod pull_requests; mod pull_requests;
mod repo; mod repo;
mod repo_list; mod repo_list;
@@ -15,6 +16,7 @@ use gpui::prelude::*;
use gpui::{AnyElement, App, div}; use gpui::{AnyElement, App, div};
use gpui_component::{Sizable as _, h_flex}; use gpui_component::{Sizable as _, h_flex};
pub use inbox::InboxView; pub use inbox::InboxView;
pub(crate) use placeholder_panel::PlaceholderPanel;
pub use repo::RepoDetailView; pub use repo::RepoDetailView;
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
pub use repo_list::RepoListView; pub use repo_list::RepoListView;
@@ -0,0 +1,48 @@
use dock::{BasePanel, Panel, PanelEvent};
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, Render, SharedString, Window,
};
use signed_ui::placeholder;
/// A center panel reserving the place of a view that is not built yet.
pub struct PlaceholderPanel {
name: &'static str,
title: SharedString,
focus_handle: FocusHandle,
}
impl PlaceholderPanel {
pub(crate) fn new(name: &'static str, title: &'static str, cx: &mut Context<Self>) -> Self {
Self {
name,
title: SharedString::from(title),
focus_handle: cx.focus_handle(),
}
}
}
impl BasePanel for PlaceholderPanel {
fn panel_name(&self) -> &'static str {
self.name
}
}
impl Panel for PlaceholderPanel {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.title.clone()
}
}
impl EventEmitter<PanelEvent> for PlaceholderPanel {}
impl Focusable for PlaceholderPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PlaceholderPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
placeholder("Nothing here yet.", cx)
}
}
@@ -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);
+25 -43
View File
@@ -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))
} }
+2 -1
View File
@@ -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| {
+10 -2
View File
@@ -1,13 +1,13 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use signed_state::CheckoutStatus; use signed_state::{CheckoutStatus, CheckoutSyncStatus};
#[derive(Default)] #[derive(Default)]
pub(super) struct Banners { pub(super) struct Banners {
dismissed: HashSet<(PathBuf, String)>, dismissed: HashSet<(PathBuf, String)>,
sync_dismissed: HashSet<PathBuf>,
ready_requested: bool, ready_requested: bool,
/// Re-requested only when the announced HEAD or the base default changes.
ready_head: Option<String>, ready_head: Option<String>,
ready_statuses: Vec<CheckoutStatus>, ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>, push_statuses: Vec<CheckoutStatus>,
@@ -24,6 +24,14 @@ impl Banners {
.insert((status.path.clone(), status.branch.clone())); .insert((status.path.clone(), status.branch.clone()));
} }
pub(super) fn sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
self.sync_dismissed.contains(&checkout.path)
}
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
self.sync_dismissed.insert(checkout.path.clone());
}
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) { pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
(self.ready_requested, &self.ready_head) (self.ready_requested, &self.ready_head)
} }
+4 -4
View File
@@ -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| {
+4 -2
View File
@@ -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| {
+73 -2
View File
@@ -2,16 +2,19 @@ use std::path::PathBuf;
use assets::CustomIconName; use assets::CustomIconName;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{App, Entity, WeakEntity, Window, px}; use gpui::{App, Entity, SharedString, WeakEntity, Window, px};
use gpui_base::h_flex; use gpui_base::h_flex;
use gpui_base::input::TextareaState; use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form}; use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::select::{Select, SelectState};
use gpui_component::{ActiveTheme, Disableable, WindowExt}; use gpui_component::{ActiveTheme, Disableable, WindowExt};
use settings::SettingsStore; use settings::SettingsStore;
use signed_git::Repo;
use signed_state::Backend; use signed_state::Backend;
use signed_ui::SelectOption;
use super::RepoDetailView; use super::RepoDetailView;
use crate::views::dialog_state::{DialogProgress, error_row}; use crate::views::dialog_state::{DialogProgress, error_row};
@@ -48,8 +51,11 @@ pub fn open(
.auto_grow(3, 5) .auto_grow(3, 5)
.placeholder("Short description") .placeholder("Short description")
}); });
let branch_select: Entity<SelectState<Vec<SelectOption>>> =
cx.new(|cx| SelectState::new(Vec::new(), None, window, cx));
load_user_grasp_servers(grasp_state.clone(), window, cx); load_user_grasp_servers(grasp_state.clone(), window, cx);
load_branches(local_path.clone(), branch_select.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish this local repository to Nostr."; const DESC: &str = "Publish this local repository to Nostr.";
@@ -57,6 +63,7 @@ pub fn open(
let name_input = name_input.clone(); let name_input = name_input.clone();
let desc_input = desc_input.clone(); let desc_input = desc_input.clone();
let relay_input = relay_input.clone(); let relay_input = relay_input.clone();
let branch_select = branch_select.clone();
let state = state.clone(); let state = state.clone();
let grasp_state = grasp_state.clone(); let grasp_state = grasp_state.clone();
let local_path = local_path.clone(); let local_path = local_path.clone();
@@ -105,6 +112,12 @@ pub fn open(
.child(local_path.display().to_string()), .child(local_path.display().to_string()),
), ),
) )
.child(
field()
.label("Default branch")
.description("The local branch to announce as the default")
.child(Select::new(&branch_select).w_full()),
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx)), .child(grasp_servers_field(&grasp_state, &relay_input, cx)),
) )
.children(error_row(&error, cx)) .children(error_row(&error, cx))
@@ -120,6 +133,7 @@ pub fn open(
.on_click({ .on_click({
let name_input = name_input.clone(); let name_input = name_input.clone();
let desc_input = desc_input.clone(); let desc_input = desc_input.clone();
let branch_select = branch_select.clone();
let state = state.clone(); let state = state.clone();
let grasp_state = grasp_state.clone(); let grasp_state = grasp_state.clone();
let local_path = local_path.clone(); let local_path = local_path.clone();
@@ -129,6 +143,7 @@ pub fn open(
init_repository( init_repository(
local_path.clone(), local_path.clone(),
(name_input.clone(), desc_input.clone()), (name_input.clone(), desc_input.clone()),
branch_select.clone(),
state.clone(), state.clone(),
grasp_state.clone(), grasp_state.clone(),
view.clone(), view.clone(),
@@ -143,9 +158,11 @@ pub fn open(
}); });
} }
#[allow(clippy::too_many_arguments)]
fn init_repository( fn init_repository(
local_path: PathBuf, local_path: PathBuf,
inputs: (Entity<InputState>, Entity<TextareaState>), inputs: (Entity<InputState>, Entity<TextareaState>),
branch_select: Entity<SelectState<Vec<SelectOption>>>,
state: Entity<InitRepoState>, state: Entity<InitRepoState>,
grasp_state: Entity<GraspServersState>, grasp_state: Entity<GraspServersState>,
view: WeakEntity<RepoDetailView>, view: WeakEntity<RepoDetailView>,
@@ -156,6 +173,11 @@ fn init_repository(
let name = name_input.read(cx).value().trim().to_owned(); let name = name_input.read(cx).value().trim().to_owned();
let description = desc_input.read(cx).value().trim().to_owned(); let description = desc_input.read(cx).value().trim().to_owned();
let servers = grasp_state.read(cx).grasp_servers.clone(); let servers = grasp_state.read(cx).grasp_servers.clone();
let default_branch = branch_select
.read(cx)
.selected_value()
.map(|value| value.trim().to_owned())
.filter(|branch| !branch.is_empty());
if name.is_empty() { if name.is_empty() {
state.update(cx, |state, _| state.fail("Repository name is required")); state.update(cx, |state, _| state.fail("Repository name is required"));
@@ -171,7 +193,14 @@ fn init_repository(
let backend = Backend::global(cx); let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| { let task = backend.update(cx, |backend, cx| {
backend.publish_local_repo(local_path.clone(), &name, &description, servers, cx) backend.publish_local_repo(
local_path.clone(),
&name,
&description,
servers,
default_branch,
cx,
)
}); });
let handle = window.window_handle(); let handle = window.window_handle();
@@ -199,3 +228,45 @@ fn init_repository(
}) })
.detach(); .detach();
} }
/// Fills the default-branch selector with the repository's local branches,
/// preselecting the checked-out one.
fn load_branches(
local_path: PathBuf,
select: Entity<SelectState<Vec<SelectOption>>>,
window: &mut Window,
cx: &mut App,
) {
let handle = window.window_handle();
cx.spawn(async move |cx| {
let loaded = cx
.background_spawn(async move {
let repo = Repo::open(&local_path)?;
let current: Option<SharedString> = repo.current_branch().map(Into::into);
Ok::<_, anyhow::Error>((repo.branches()?, current))
})
.await;
let _ = cx.update_window(handle, |_, window, cx| {
let Ok((branches, current)) = loaded.map_err(|error| {
log::warn!("failed to list branches for the default branch selector: {error:#}")
}) else {
return;
};
let options: Vec<SelectOption> = branches
.into_iter()
.map(|branch| SelectOption::new(branch.clone(), branch))
.collect();
select.update(cx, |state, cx| {
state.set_items(options, window, cx);
if let Some(current) = current.as_ref() {
state.set_selected_value(current, window, cx);
}
});
});
})
.detach();
}
+174 -80
View File
@@ -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, RefSync, Repo};
use signed_state::{ use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror, Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, SyncStatusStore,
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;
@@ -155,6 +153,7 @@ impl RepoDetailView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let files = cx.new(RepoFilesView::new); let files = cx.new(RepoFilesView::new);
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone())); let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
let refs = RefSwitcher::new(window, cx); let refs = RefSwitcher::new(window, cx);
@@ -191,6 +190,11 @@ impl RepoDetailView {
} }
})); }));
// The sync-to-Nostr banner reads the global sync store directly.
subscriptions.push(cx.observe(&sync_status, |_this, _sync, cx| {
cx.notify();
}));
// Defer loading the repository until the window is ready. // Defer loading the repository until the window is ready.
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.load_repo(window, cx); this.load_repo(window, cx);
@@ -326,7 +330,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 +364,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 +381,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 +408,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 +520,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 +606,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 +653,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 +704,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))
@@ -846,10 +834,6 @@ impl RepoDetailView {
} }
/// Open the upstream repository, the `u` tag of this fork's announcement. /// Open the upstream repository, the `u` tag of this fork's announcement.
///
/// The announcement may not be in the local database yet. The panel opens
/// from the address and fills in when the store loads it; the store's
/// `subscribe_remote` fetches it from the bootstrap relays.
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(addr) = self let Some(addr) = self
.announcement(cx) .announcement(cx)
@@ -1523,8 +1507,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;
} }
} }
@@ -1553,6 +1541,26 @@ impl RepoDetailView {
.find(|status| !self.banners.dismissal(status)) .find(|status| !self.banners.dismissal(status))
} }
/// The first checkout of this owned repository with commits not on Nostr.
///
/// Not dismissed in this panel.
fn sync_suggestion(&self, cx: &App) -> Option<CheckoutSyncStatus> {
let store = self.store.read(cx);
let addr = store.addr()?;
let user = Backend::global(cx).read(cx).current_user()?;
if !store.is_author(&user) {
return None;
}
let sync_status = SyncStatusStore::global(cx);
let statuses = sync_status.read(cx).statuses_of(addr);
statuses.into_iter().find(|checkout| {
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
})
}
pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> { pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?; let status = self.push_suggestion(cx)?;
let path = status.path.clone(); let path = status.path.clone();
@@ -1638,6 +1646,102 @@ impl RepoDetailView {
) )
} }
pub(super) fn render_sync_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let checkout = self.sync_suggestion(cx)?;
let path = checkout.path.clone();
// The push busy flag lives on the store; it disables the banner's triggers.
let pushing = self.store.read(cx).pushing;
let (branch, ahead) = checkout
.status
.refs
.iter()
.filter_map(|(name, sync)| match sync {
RefSync::LocalAhead { ahead } => Some((name, *ahead)),
RefSync::Diverged { ahead, .. } => Some((name, *ahead)),
_ => None,
})
.max_by_key(|(_, ahead)| *ahead)?;
let branch = SharedString::from(branch.clone());
let commits = if ahead == 1 {
SharedString::from("1 commit")
} else {
SharedString::from(format!("{} commits", ahead))
};
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().muted)
.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().info)
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(branch),
)
.child("is")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(commits),
)
.child("ahead of Nostr"),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("sync-banner")
.icon(CustomIconName::Refresh)
.label("Sync now")
.small()
.info()
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
)
.child(
Button::new("sync-banner-dismiss")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.disabled(pushing)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banners.sync_dismiss(&checkout);
cx.notify();
})),
),
)
.into_any_element(),
)
}
pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> { pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let store = self.store.read(cx); let store = self.store.read(cx);
let warning = store.last_push_warning.clone()?; let warning = store.last_push_warning.clone()?;
@@ -1802,6 +1906,12 @@ impl RepoDetailView {
/// The Files tab body, or the clone/initial-load spinner. /// The Files tab body, or the clone/initial-load spinner.
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement { fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
let label = if self.store.read(cx).path.is_some() {
"Loading repository..."
} else {
"Cloning repository..."
};
return v_flex() return v_flex()
.flex_1() .flex_1()
.size_full() .size_full()
@@ -1813,7 +1923,7 @@ impl RepoDetailView {
div() div()
.text_sm() .text_sm()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child("Cloning repository..."), .child(label),
) )
.into_any_element(); .into_any_element();
} }
@@ -1857,7 +1967,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 +2010,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 +2062,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,
}) })
} }
@@ -2075,7 +2168,8 @@ impl Render for RepoDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let banner = self let banner = self
.render_ready_banner(cx) .render_ready_banner(cx)
.or_else(|| self.render_push_banner(cx)); .or_else(|| self.render_push_banner(cx))
.or_else(|| self.render_sync_banner(cx));
let error = self.error.clone().or_else(|| { let error = self.error.clone().or_else(|| {
self.store self.store
+8 -17
View File
@@ -169,21 +169,6 @@ impl RepoListView {
cx.notify(); cx.notify();
} }
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_repo_panel(
&self.dock_area,
&announcement.addr(),
Some(announcement),
window,
cx,
);
}
fn render_card( fn render_card(
&self, &self,
ix: usize, ix: usize,
@@ -228,7 +213,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}")))
}); });
@@ -311,7 +296,13 @@ impl RepoListView {
.on_click(cx.listener({ .on_click(cx.listener({
let announcement = announcement.clone(); let announcement = announcement.clone();
move |this, _ev, window, cx| { move |this, _ev, window, cx| {
this.open_repo(&announcement, window, cx); open_repo_panel(
&this.dock_area,
&announcement.addr(),
Some(&announcement),
window,
cx,
);
} }
})) }))
.into_any_element() .into_any_element()
@@ -10,7 +10,6 @@ use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{Disableable, IconName, WindowExt, h_flex}; use gpui_component::{Disableable, IconName, WindowExt, h_flex};
use settings::SettingsStore; use settings::SettingsStore;
use signed_core::Announcement;
use signed_state::{Backend, CheckoutsStore}; use signed_state::{Backend, CheckoutsStore};
use super::super::open_repo_panel; use super::super::open_repo_panel;
@@ -232,7 +231,13 @@ fn create_repository(
store.record(local_path.clone(), announcement.addr(), cx); store.record(local_path.clone(), announcement.addr(), cx);
}); });
cx.open_with_system(&local_path); cx.open_with_system(&local_path);
open_repo(dock_area, announcement, window, cx); open_repo_panel(
&dock_area,
&announcement.addr(),
Some(&announcement),
window,
cx,
);
}) })
.ok(); .ok();
} }
@@ -245,18 +250,3 @@ fn create_repository(
}) })
.detach(); .detach();
} }
fn open_repo(
dock_area: WeakEntity<DockArea>,
announcement: Announcement,
window: &mut Window,
cx: &mut App,
) {
open_repo_panel(
&dock_area,
&announcement.addr(),
Some(&announcement),
window,
cx,
);
}
@@ -1,8 +1,337 @@
use gpui::{App, Window, px}; use gpui::prelude::*;
use gpui_component::WindowExt; use gpui::{AnyWindowHandle, App, Entity, Subscription, Task, Window, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{Disableable, WindowExt};
use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
#[derive(Clone, Copy, Default)]
enum Stage {
#[default]
Credential,
/// An `nsec` was entered; ask for a passphrase to encrypt it with.
Encrypt,
/// An `ncryptsec` was entered; ask for its passphrase to decrypt it.
Decrypt,
}
/// The credential entered on the first step.
enum Credential {
Nsec(String),
Ncryptsec(String),
Bunker(String),
}
impl Credential {
fn parse(input: &str) -> Option<Self> {
let input = input.trim();
if input.starts_with("nsec1") {
Some(Self::Nsec(input.to_owned()))
} else if input.starts_with("ncryptsec1") {
Some(Self::Ncryptsec(input.to_owned()))
} else if input.starts_with("bunker://") {
Some(Self::Bunker(input.to_owned()))
} else {
None
}
}
}
/// State of the import dialog, so the current step and async results can be rendered.
#[derive(Default)]
pub struct ImportState {
stage: Stage,
credential: String,
progress: DialogProgress,
/// Keeps the subscriptions alive while the dialog is open.
subscriptions: Vec<Subscription>,
}
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let secret_input =
cx.new(|cx| InputState::new(window, cx).placeholder("secret key or bunker://"));
let pass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Passphrase to protect your key")
.masked(true)
});
let repass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Repeat passphrase")
.masked(true)
});
let handle = window.window_handle();
let state = cx.new(|_| ImportState::default());
// Enter in any field submits the step the dialog is on.
let mut subscriptions = Vec::new();
for input in [&secret_input, &pass_input, &repass_input] {
let secret_input = secret_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.clone();
subscriptions.push(cx.subscribe(input, move |_input, event, cx| {
if matches!(event, InputEvent::PressEnter { .. }) {
submit(
&secret_input,
&pass_input,
&repass_input,
&state,
&handle,
cx,
);
}
}));
}
state.update(cx, |state, _cx| {
state.subscriptions = subscriptions;
});
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
dialog.title("Import identity").width(px(400.)) let secret_input = secret_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |content, _window, cx| {
let stage = state.read(cx).stage;
let busy = state.read(cx).progress.busy;
let error = state.read(cx).progress.error.clone();
content
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Import identity"))
.child(DialogDescription::new().child(match stage {
Stage::Credential => {
"Paste the secret key or bunker URI of an existing identity."
}
Stage::Encrypt => {
"Choose a passphrase to encrypt your key on this device."
}
Stage::Decrypt => "Enter the passphrase used to encrypt this key.",
})),
)
.child(match stage {
Stage::Credential => v_form().child(
field()
.label("Secret key or bunker URI")
.required(true)
.child(Input::new(&secret_input)),
),
Stage::Encrypt => v_form()
.child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&pass_input)),
)
.child(field().required(true).child(Input::new(&repass_input))),
Stage::Decrypt => v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&pass_input)),
),
})
.children(error_row(&error, cx))
.child(
DialogFooter::new().justify_end().child(
Button::new("import")
.primary()
.label(match stage {
Stage::Credential => "Continue",
Stage::Encrypt => "Import identity",
Stage::Decrypt => "Unlock",
})
.loading(busy)
.disabled(busy)
.on_click({
let secret_input = secret_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.clone();
move |_ev, _window, cx| {
submit(
&secret_input,
&pass_input,
&repass_input,
&state,
&handle,
cx,
);
}
}),
),
)
})
}); });
} }
fn submit(
secret_input: &Entity<InputState>,
pass_input: &Entity<InputState>,
repass_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
if state.read(cx).progress.busy {
return;
}
match state.read(cx).stage {
Stage::Credential => start_import(secret_input, state, handle, cx),
Stage::Encrypt => import_nsec(pass_input, repass_input, state, handle, cx),
Stage::Decrypt => import_ncryptsec(pass_input, state, handle, cx),
}
}
/// Parses the entered credential and either moves to the passphrase step or connects to a bunker.
fn start_import(
secret_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let credential = match Credential::parse(&secret_input.read(cx).value()) {
Some(credential) => credential,
None => {
state.update(cx, |state, _| {
state
.progress
.fail("Enter an nsec, ncryptsec or bunker:// URI");
});
return;
}
};
match credential {
Credential::Nsec(secret) => {
state.update(cx, |state, _| {
state.credential = secret;
state.stage = Stage::Encrypt;
state.progress.error = None;
});
}
Credential::Ncryptsec(secret) => {
state.update(cx, |state, _| {
state.credential = secret;
state.stage = Stage::Decrypt;
state.progress.error = None;
});
}
Credential::Bunker(uri) => {
state.update(cx, |state, _| state.progress.begin());
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx));
finish_import(task, state, handle, cx);
}
}
}
/// Encrypts the entered `nsec` with the passphrase and signs in with it.
fn import_nsec(
pass_input: &Entity<InputState>,
repass_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let backend = Backend::global(cx);
let nsec = state.read(cx).credential.clone();
let pass = pass_input.read(cx).value().to_string();
let repass = repass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, _| {
state.progress.fail("Passphrase must not be empty");
});
return;
}
if pass != repass {
state.update(cx, |state, _| {
state.progress.fail("Passphrases do not match");
});
return;
}
state.update(cx, |state, _cx| {
state.progress.begin();
});
let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx));
finish_import(task, state, handle, cx);
}
/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it.
fn import_ncryptsec(
pass_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let backend = Backend::global(cx);
let ncryptsec = state.read(cx).credential.clone();
let pass = pass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, _| {
state.progress.fail("Passphrase must not be empty");
});
return;
}
state.update(cx, |state, _cx| {
state.progress.begin();
});
let task = backend.update(cx, |backend, cx| {
backend.import_ncryptsec(&ncryptsec, &pass, cx)
});
finish_import(task, state, handle, cx);
}
/// Runs the import task to completion, closing the dialog on success.
fn finish_import<T: 'static>(
task: Task<Result<T, anyhow::Error>>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let handle = *handle;
let state = state.clone();
cx.spawn(async move |cx| match task.await {
Ok(_) => {
cx.update_window(handle, |_, window, cx| {
window.close_dialog(cx);
})
.ok();
}
Err(e) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| state.progress.fail(e.to_string()));
})
.ok();
}
})
.detach();
}
+203 -63
View File
@@ -10,22 +10,25 @@ use dock::{
}; };
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit,
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list,
white,
}; };
use gpui_base::Button as BaseButton; use gpui_base::Button as BaseButton;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_component::dialog::DialogButtonProps;
use gpui_component::input::InputState; use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::menu::{DropdownMenu, PopupMenuItem};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex};
use nostr::prelude::RelayUrl; 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, SyncStatusStore,
}; };
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers}; use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel};
mod create_repo_dialog; mod create_repo_dialog;
pub(crate) mod grasp_servers; pub(crate) mod grasp_servers;
@@ -54,14 +57,18 @@ pub struct SidebarPanel {
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
inbox: Option<WeakEntity<InboxView>>, inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
profile: Option<WeakEntity<PlaceholderPanel>>,
relays: Option<WeakEntity<PlaceholderPanel>>,
banner: SharedString, banner: SharedString,
scanning: bool,
/// User's announced repositories. /// User's announced repositories.
announcements: Arc<Vec<Announcement>>, announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet. /// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<ResolvedLocalRepo>>, local_repos: Arc<Vec<ResolvedLocalRepo>>,
scanning: bool,
/// Unpushed commit counts per announced repository. /// Unpushed commit counts per announced repository.
unpushed: HashMap<RepoAddr, usize>, unpushed: HashMap<RepoAddr, usize>,
/// Commits not on Nostr per announced repository.
unsynced: HashMap<RepoAddr, usize>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -71,6 +78,7 @@ impl SidebarPanel {
let repos = RepoListStore::global(cx); let repos = RepoListStore::global(cx);
let local = LocalReposStore::global(cx); let local = LocalReposStore::global(cx);
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let mut subscriptions = Vec::new(); let mut subscriptions = Vec::new();
@@ -108,16 +116,25 @@ impl SidebarPanel {
} }
})); }));
subscriptions.push(cx.observe(&sync_status, |this, _sync, cx| {
if this.refresh_unsynced(cx) {
cx.notify();
}
}));
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
inbox: None, inbox: None,
explore: None, explore: None,
profile: None,
relays: None,
banner: pick_banner(), banner: pick_banner(),
announcements: Arc::new(Vec::new()), announcements: Arc::new(Vec::new()),
local_repos: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()),
scanning: false, scanning: false,
unpushed: HashMap::new(), unpushed: HashMap::new(),
unsynced: HashMap::new(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -136,7 +153,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)
}; };
@@ -152,6 +169,7 @@ impl SidebarPanel {
if announcements_changed { if announcements_changed {
self.request_push_watches(cx); self.request_push_watches(cx);
self.unpushed.clear(); self.unpushed.clear();
self.unsynced.clear();
} }
announcements_changed || local_changed || scanning_changed announcements_changed || local_changed || scanning_changed
@@ -177,6 +195,26 @@ impl SidebarPanel {
true true
} }
fn refresh_unsynced(&mut self, cx: &mut Context<Self>) -> bool {
let sync_status = SyncStatusStore::global(cx);
let mut unsynced = HashMap::with_capacity(self.announcements.len());
for announcement in self.announcements.iter() {
let addr = announcement.addr();
let count = sync_status.read(cx).unsynced(&addr);
if count > 0 {
unsynced.insert(addr, count);
}
}
if unsynced == self.unsynced {
return false;
}
self.unsynced = unsynced;
true
}
fn request_push_watches(&self, cx: &mut Context<Self>) { fn request_push_watches(&self, cx: &mut Context<Self>) {
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| { checkouts.update(cx, |checkouts, cx| {
@@ -221,6 +259,51 @@ impl SidebarPanel {
.ok(); .ok();
} }
fn open_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
Self::open_placeholder_panel(
&self.dock_area,
&mut self.profile,
"profile",
"Profile",
window,
cx,
);
}
fn open_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
Self::open_placeholder_panel(
&self.dock_area,
&mut self.relays,
"relays",
"Relays",
window,
cx,
);
}
/// Opens one of the not-yet-built panels, reusing the already-open one.
fn open_placeholder_panel(
dock_area: &WeakEntity<DockArea>,
slot: &mut Option<WeakEntity<PlaceholderPanel>>,
name: &'static str,
title: &'static str,
window: &mut Window,
cx: &mut Context<Self>,
) {
if slot.as_ref().and_then(WeakEntity::upgrade).is_some() {
return;
}
let panel = cx.new(|cx| PlaceholderPanel::new(name, title, cx));
*slot = Some(panel.downgrade());
dock_area
.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
})
.ok();
}
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name"));
let pass_input = cx.new(|cx| { let pass_input = cx.new(|cx| {
@@ -238,25 +321,6 @@ impl SidebarPanel {
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx); onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
} }
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
create_repo_dialog::open(self.dock_area.clone(), window, cx);
}
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_repo_panel(
&self.dock_area,
&announcement.addr(),
Some(announcement),
window,
&mut *cx,
);
}
/// The detail view offers to publish it to NIP-34. /// The detail view offers to publish it to NIP-34.
fn open_local_repo( fn open_local_repo(
&mut self, &mut self,
@@ -370,7 +434,11 @@ impl SidebarPanel {
.small() .small()
.ghost() .ghost()
.on_click(cx.listener(|this, _ev, window, cx| { .on_click(cx.listener(|this, _ev, window, cx| {
this.open_create_repo(window, cx); create_repo_dialog::open(
this.dock_area.clone(),
window,
cx,
);
})), })),
), ),
), ),
@@ -446,33 +514,53 @@ impl SidebarPanel {
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall(); PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
let announcement = announcement.clone(); let announcement = announcement.clone();
let unpushed = self let addr = announcement.addr();
.unpushed let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
.get(&announcement.addr()) let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
.copied()
.unwrap_or(0);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
if unpushed > 0 { if unpushed > 0 || unsynced > 0 {
row = row.suffix( let badges = h_flex().gap_0p5().flex_shrink_0();
v_flex()
.flex_shrink_0() let badges = if unpushed > 0 {
.size_4() badges.child(Self::count_badge(unpushed, cx.theme().red_light))
.items_center() } else {
.justify_center() badges
.rounded_full() };
.line_height(relative(1.))
.bg(cx.theme().red_light) let badges = if unsynced > 0 {
.text_color(white()) badges.child(Self::count_badge(unsynced, cx.theme().info))
.text_size(px(8.)) } else {
.child(SharedString::from(unpushed.to_string())), badges
); };
row = row.suffix(badges);
} }
row.on_click( row.on_click(cx.listener(move |this, _ev, window, cx| {
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)), open_repo_panel(
) &this.dock_area,
&announcement.addr(),
Some(&announcement),
window,
&mut *cx,
);
}))
}
fn count_badge(count: usize, color: Hsla) -> Div {
v_flex()
.flex_shrink_0()
.size_4()
.items_center()
.justify_center()
.rounded_full()
.line_height(relative(1.))
.bg(color)
.text_color(white())
.text_size(px(8.))
.child(SharedString::from(count.to_string()))
} }
fn render_local_row( fn render_local_row(
@@ -524,10 +612,6 @@ impl SidebarPanel {
})) }))
} }
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx);
}
/// The user avatar and name, wired into the titlebar drag area. /// The user avatar and name, wired into the titlebar drag area.
fn render_user( fn render_user(
&self, &self,
@@ -537,6 +621,7 @@ impl SidebarPanel {
) -> impl IntoElement { ) -> impl IntoElement {
let name = profile.name(); let name = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let panel = cx.entity().downgrade();
title_bar_drag_handlers( title_bar_drag_handlers(
h_flex() h_flex()
@@ -545,12 +630,44 @@ impl SidebarPanel {
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.))) .when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
.child( .child(
div().child( div().child(
Button::new("user").text().dropdown_caret(true).child( Button::new("user")
h_flex() .text()
.gap_1() .dropdown_caret(true)
.child(Avatar::new(name.clone()).picture(picture)) .child(
.child(div().text_xs().font_semibold().child(name)), h_flex()
), .gap_1()
.child(Avatar::new(name.clone()).picture(picture))
.child(div().text_xs().font_semibold().child(name)),
)
.dropdown_menu(move |menu, _, _| {
menu.item(PopupMenuItem::new("View Profile").on_click({
let panel = panel.clone();
move |_, window, cx| {
let _ = panel.update(cx, |this, cx| {
this.open_profile(window, cx);
});
}
}))
.item(PopupMenuItem::new("View Relays").on_click({
let panel = panel.clone();
move |_, window, cx| {
let _ = panel.update(cx, |this, cx| {
this.open_relays(window, cx);
});
}
}))
.separator()
.item(
PopupMenuItem::element(|_, cx| {
div().text_color(cx.theme().danger).child("Exit")
})
.on_click(
|_, window, cx| {
open_exit_dialog(window, cx);
},
),
)
}),
), ),
), ),
window, window,
@@ -610,7 +727,7 @@ impl SidebarPanel {
})), })),
) )
.child( .child(
BaseButton::new("onboarding") BaseButton::new("import")
.h_flex() .h_flex()
.h_8() .h_8()
.px_2() .px_2()
@@ -619,8 +736,8 @@ impl SidebarPanel {
.hover(|this| this.bg(gpui::white().opacity(0.2))) .hover(|this| this.bg(gpui::white().opacity(0.2)))
.active(|this| this.bg(gpui::white().opacity(0.4))) .active(|this| this.bg(gpui::white().opacity(0.4)))
.child(div().text_sm().child("Import identity")) .child(div().text_sm().child("Import identity"))
.on_click(cx.listener(|this, _ev, window, cx| { .on_click(cx.listener(|_this, _ev, window, cx| {
this.open_import(window, cx) import_dialog::open(window, cx)
})), })),
), ),
), ),
@@ -794,3 +911,26 @@ impl Render for SidebarPanel {
) )
} }
} }
fn open_exit_dialog(window: &mut Window, cx: &mut App) {
window.open_alert_dialog(cx, |alert, _, _| {
alert
.title("Exit Signed?")
.description(
"You will be signed out on this device and will need to sign in again on the next session.",
)
.button_props(
DialogButtonProps::default()
.ok_text("Exit")
.ok_variant(ButtonVariant::Danger)
.show_cancel(true),
)
.on_ok(|_, _, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.sign_out(cx);
});
true
})
});
}
+7 -7
View File
@@ -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::{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);
}
}, },
)); ));
@@ -101,17 +106,12 @@ impl Workspace {
} }
impl Render for Workspace { impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let dialog_layer = Root::render_dialog_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div() div()
.id("workspace") .id("workspace")
.v_flex() .v_flex()
.size_full() .size_full()
.relative() .relative()
.child(self.dock.clone()) .child(self.dock.clone())
.children(notification_layer)
.children(dialog_layer)
} }
} }
+5
View File
@@ -7,6 +7,8 @@ use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
use gpui_platform::application; use gpui_platform::application;
use settings::{AppearanceMode, SettingsStore}; use settings::{AppearanceMode, SettingsStore};
actions!(signed, [Quit]);
fn main() { fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@@ -17,6 +19,9 @@ fn main() {
gpui_component::init(cx); gpui_component::init(cx);
theme::init(cx); theme::init(cx);
cx.bind_keys([KeyBinding::new("secondary-q", Quit, None)]);
cx.on_action(|_: &Quit, cx| cx.quit());
// The persisted settings must load before the theme is applied. // The persisted settings must load before the theme is applied.
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx)); let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
SettingsStore::set_global(store.clone(), cx); SettingsStore::set_global(store.clone(), cx);
+2 -16
View File
@@ -1,18 +1,4 @@
# TODO # TODO
## `BackendEvent::SyncProgress` - Profile panel: the sidebar user menu's "View profile" opens an empty placeholder panel; build the actual profile view.
- Relays panel: the sidebar user menu's "View relays" opens an empty placeholder panel; build the actual relays view.
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`.
+93
View File
@@ -0,0 +1,93 @@
# Nostr sync detection plan
Detect when a local repository has diverged from its NIP-34 state on Nostr
(kind 30618) and suggest syncing when the app opens.
Scope: repositories already initialized on Nostr (NIP-34 binding present).
Purely local repositories without a binding do not get this feature.
Scenario: the repository is initialized on Nostr and also pushed to Gitea.
Work continues locally and is pushed to Gitea only, so the Nostr state falls
behind. Signed should notice the difference on startup and offer to sync.
## Prior art
- nak (`git.go`): `nak git sync` fetches the latest kind 30617/30618 events
from relays and writes them to local refs (`refs/nip34/state/*`); push and
pull then compare against those refs with ordinary git machinery
(e.g. `merge-base --is-ancestor` for fast-forward checks).
- ngit-cli: the same idea — the Nostr state is materialized as git refs in the
repository, then ahead/behind comparison applies.
## Current state of the codebase
| Piece | Location | Status |
|---|---|---|
| NIP-34 binding detection | `Repo::nip34_binding()` in `signed_git/src/repo.rs` | done |
| Full Nostr state (refs + HEAD) | `RepoState::parse` in `signed_core/src/state.rs` | parsed, but `RepoStore` keeps only `head` and drops the refs |
| Push-to-Nostr flow | `Backend::push_repo_from` / `RepoStore::push_checkout` / `push_repository` | done; stages the state event on grasp relays, pushes objects, broadcasts 30618 |
| "Ahead of origin" detection | `CheckoutsStore::checkout_push_status` — local branch vs `refs/remotes/origin/<branch>` | exists, but that is local vs Gitea, not local vs Nostr |
| UI suggestion surfaces | Sidebar unpushed badge (`SidebarPanel::refresh_unpushed`), push banner (`RepoDetailView::push_suggestion` / `render_push_banner`) | pattern exists for origin; nothing for Nostr |
Gap: nothing compares the local repository's refs against the Nostr
`RepoState` refs, and no "sync to Nostr" suggestion is based on that
comparison.
## Plan
### Phase 1 — Divergence computation in `signed_git`
Add a pure function in a new `sync.rs` module that takes local `ref_state()`
and the Nostr `RepoState.refs` and classifies each `refs/heads/*` ref:
```rust
pub enum RefSync {
InSync,
LocalAhead { ahead: usize }, // fast-forwardable push
RemoteAhead { behind: usize }, // Nostr has newer commits
Diverged { ahead: usize, behind: usize },
LocalOnly, // branch never published
RemoteOnly, // branch exists only on Nostr
}
pub struct RepoSyncStatus {
pub refs: Vec<(String, RefSync)>, // branch name -> classification
pub ahead_total: usize, // summary for badges
pub behind_total: usize,
}
```
Implemented with the existing `Repo::merge_base` and `commits_since` /
`commits_ahead` — no new dependencies.
### Phase 2 — Keep Nostr state refs and add a sync-status store in `signed_state`
1. `RepoStore`: store `state_refs: Vec<(String, String)>` (and the state event
timestamp) alongside `head` in `run_refresh` instead of discarding them.
2. New `SyncStatusStore` (following the `CheckoutsStore` pattern: global
entity, background compute, notify-on-change) that:
- watches own announcements (`RepoListStore` + `Backend::current_user`;
for repositories the user does not own, sync means PRs, which is out of
scope);
- reads the latest kind 30618 event per own repository from the Nostr LMDB
(`RepoListStore::sync_own_repo_states` already ensures those events are
synced);
- for each associated checkout (`CheckoutsStore::associations_of`)
computes `RepoSyncStatus` against the local refs on the background
executor;
- recomputes on app open, `BackendEvent::Synced` / `RepoUpdates` (state
kind), after a successful push, and on a slow poll (~60s, like
`PUSH_POLL`).
### Phase 3 — UI: suggest sync
- Sidebar: out-of-sync badge on own repository rows, reusing the `unpushed`
badge pattern, visually distinct (e.g. "N commits not on Nostr" tooltip).
- RepoDetailView: banner modeled on `render_push_banner`: "main is 3 commits
ahead of Nostr — Sync now", calling the existing `store.push_checkout(path)`.
## Open decisions
1. Badge placement: sidebar only, or sidebar plus repo-detail banner in the
first pass. Recommendation: both — the banner hosts the action button, the
badge drives the click.