Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
705d4ea046 | ||
|
|
a2ae86dae2 | ||
|
|
68dbc5a731 | ||
|
|
0e9f0a33ac | ||
|
|
18433ec239 | ||
|
|
3cfdffdf82 | ||
|
|
5f37f2ff2a | ||
|
|
e77b1d8dd7 | ||
|
|
33847f6dcb |
@@ -0,0 +1,174 @@
|
|||||||
|
# Rust coding guidelines
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||||
|
* 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.
|
||||||
|
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||||
|
- Propagate errors with `?` when the calling function should handle them
|
||||||
|
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||||
|
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||||
|
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||||
|
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||||
|
* Avoid creative additions unless explicitly requested
|
||||||
|
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||||
|
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||||
|
Example:
|
||||||
|
```rust
|
||||||
|
executor.spawn({
|
||||||
|
let task_ran = task_ran.clone();
|
||||||
|
async move {
|
||||||
|
*task_ran.borrow_mut() = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
# Timers in tests
|
||||||
|
|
||||||
|
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||||
|
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||||
|
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||||
|
|
||||||
|
# GPUI
|
||||||
|
|
||||||
|
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||||
|
|
||||||
|
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||||
|
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||||
|
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||||
|
|
||||||
|
## `Window`
|
||||||
|
|
||||||
|
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||||
|
|
||||||
|
* `thing.entity_id()` returns `EntityId`
|
||||||
|
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||||
|
* `thing.read(cx: &App)` returns `&T`.
|
||||||
|
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||||
|
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||||
|
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||||
|
|
||||||
|
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||||
|
|
||||||
|
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||||
|
|
||||||
|
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||||
|
|
||||||
|
## Concurrency
|
||||||
|
|
||||||
|
All use of entities and UI rendering occurs on a single foreground thread.
|
||||||
|
|
||||||
|
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||||
|
|
||||||
|
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||||
|
|
||||||
|
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||||
|
|
||||||
|
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||||
|
|
||||||
|
* Awaiting the task in some other async context.
|
||||||
|
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||||
|
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||||
|
|
||||||
|
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||||
|
|
||||||
|
## Elements
|
||||||
|
|
||||||
|
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
struct TextWithBorder(SharedString);
|
||||||
|
|
||||||
|
impl Render for TextWithBorder {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().border_1().child(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||||
|
|
||||||
|
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||||
|
|
||||||
|
The style methods on elements are similar to those used by Tailwind CSS.
|
||||||
|
|
||||||
|
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||||
|
|
||||||
|
## Input events
|
||||||
|
|
||||||
|
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||||
|
|
||||||
|
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||||
|
|
||||||
|
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||||
|
|
||||||
|
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||||
|
|
||||||
|
## Notify
|
||||||
|
|
||||||
|
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||||
|
|
||||||
|
## Entity events
|
||||||
|
|
||||||
|
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||||
|
|
||||||
|
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||||
|
|
||||||
|
# Pull request hygiene
|
||||||
|
|
||||||
|
When an agent opens or updates a pull request, it must:
|
||||||
|
|
||||||
|
- Use a clear, correctly capitalized, imperative PR title (for example, `Fix crash in project panel`).
|
||||||
|
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||||
|
- Avoid trailing punctuation in PR titles.
|
||||||
|
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `git_ui: Add history view`).
|
||||||
|
- Include a `Release Notes:` section as the final section in the PR body.
|
||||||
|
- Use one bullet under `Release Notes:`:
|
||||||
|
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||||
|
- `- N/A` for docs-only and other non-user-facing changes.
|
||||||
|
- Format release notes exactly with a blank line after the heading, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
Release Notes:
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
```
|
||||||
|
|
||||||
|
# Rules Hygiene
|
||||||
|
|
||||||
|
These `.rules` files are read by every agent session. Keep them high-signal.
|
||||||
|
|
||||||
|
## After any agentic session
|
||||||
|
If you discover a non-obvious pattern that would help future sessions, include a **"Suggested .rules additions"** heading in your PR description with the proposed text. Do **not** edit `.rules` inline during normal feature/fix work. Reviewers decide what gets merged.
|
||||||
|
|
||||||
|
## High bar for new rules
|
||||||
|
Editing or clarifying existing rules is always welcome. New rules must meet **all three** criteria:
|
||||||
|
1. **Non-obvious** — someone familiar with the codebase would still get it wrong without the rule.
|
||||||
|
2. **Repeatedly encountered** — it came up more than once (multiple hits in one session counts).
|
||||||
|
3. **Specific enough to act on** — a concrete instruction, not a vague principle.
|
||||||
|
|
||||||
|
Rules that apply to a single crate belong in that crate's own `.rules` file, not the repo root.
|
||||||
|
|
||||||
|
## What NOT to put in `.rules`
|
||||||
|
Avoid architectural descriptions of a crate (module layout, data flow, key types). These go stale fast and the agent can gather them by reading the code. Rules should be **traps to avoid**, not **maps to follow**.
|
||||||
|
|
||||||
|
## No drive-by additions
|
||||||
|
Rules emerge from validated patterns, not one-off observations. The workflow is:
|
||||||
|
1. Agent notes a pattern during a session.
|
||||||
|
2. Team validates the pattern in code review.
|
||||||
|
3. A dedicated commit adds the rule with context on *why* it exists.
|
||||||
Generated
+10
@@ -1690,6 +1690,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diffy"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e3dc2f773b6aaa63b1a7684b8589f670a8a0146a510b74d23a401c882364b49"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -7958,6 +7967,7 @@ name = "signed_git"
|
|||||||
version = "0.1.0-alpha"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"diffy",
|
||||||
"gix",
|
"gix",
|
||||||
"gix-worktree",
|
"gix-worktree",
|
||||||
"gix-worktree-state",
|
"gix-worktree-state",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
|||||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
|
pub use model::{
|
||||||
|
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
||||||
|
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
||||||
|
};
|
||||||
pub use state::{build_state, parse_state};
|
pub use state::{build_state, parse_state};
|
||||||
pub use status::{RepoStatus, references_root, resolve_status};
|
pub use status::{RepoStatus, references_root, resolve_status};
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
||||||
fn current_commit_of(event: &Event) -> Option<String> {
|
pub fn current_commit_of(event: &Event) -> Option<String> {
|
||||||
event
|
event
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
@@ -190,6 +190,82 @@ fn current_commit_of(event: &Event) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
||||||
///
|
///
|
||||||
/// It lets clients find existing patches for a specific commit.
|
/// It lets clients find existing patches for a specific commit.
|
||||||
@@ -727,4 +803,221 @@ mod tests {
|
|||||||
vec!["patch-one", "patch-two"]
|
vec!["patch-one", "patch-two"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||||
|
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||||
|
|
||||||
|
/// Build a signed event of `kind` with the given tags and `created_at`.
|
||||||
|
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 reads_current_commit_and_branch_name() {
|
||||||
|
let pr = pr_root();
|
||||||
|
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
|
||||||
|
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_without_pr_tags() {
|
||||||
|
let pr = signed_at(Kind::GitPullRequest, vec![], 100);
|
||||||
|
assert_eq!(current_commit_of(&pr), None);
|
||||||
|
assert_eq!(branch_name_of(&pr), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_update_ignores_roots_without_revisions() {
|
||||||
|
let root = pr_root();
|
||||||
|
assert!(latest_update([&root].into_iter(), &root).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_KEYS: [&str; 3] = [
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Build a signed kind-30617 event for `owner` with the given tags.
|
||||||
|
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));
|
||||||
|
|
||||||
|
// The user's fork comes first, then the other author's.
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ gix = { workspace = true, features = ["revision", "blob-diff"] }
|
|||||||
gix-worktree = "0.56"
|
gix-worktree = "0.56"
|
||||||
gix-worktree-state = "0.34"
|
gix-worktree-state = "0.34"
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
diffy = "0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
+174
-292
@@ -4,10 +4,12 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||||
|
use diffy::{Hunk, Line};
|
||||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||||
use gix::interrupt::IS_INTERRUPTED;
|
use gix::interrupt::IS_INTERRUPTED;
|
||||||
use gix::progress::Discard;
|
use gix::progress::Discard;
|
||||||
use signed_core::RepoAddr;
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
|
||||||
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -44,7 +46,11 @@ impl GitCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open the existing clone, fetching it first.
|
/// Open the existing clone, fetching it first.
|
||||||
pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result<gix::Repository> {
|
pub fn ensure_clone<U: AsRef<str>>(
|
||||||
|
&self,
|
||||||
|
addr: &RepoAddr,
|
||||||
|
clone_urls: &[U],
|
||||||
|
) -> Result<gix::Repository> {
|
||||||
let path = self.repo_path(addr);
|
let path = self.repo_path(addr);
|
||||||
|
|
||||||
if let Some(repo) = self.open(addr)? {
|
if let Some(repo) = self.open(addr)? {
|
||||||
@@ -122,7 +128,7 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
|||||||
/// Clone into `path` from the first working URL in `clone_urls`.
|
/// Clone into `path` from the first working URL in `clone_urls`.
|
||||||
///
|
///
|
||||||
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
||||||
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
|
pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
bail!("destination {} already exists", path.display());
|
bail!("destination {} already exists", path.display());
|
||||||
}
|
}
|
||||||
@@ -347,14 +353,14 @@ fn transport_url(url: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
||||||
/// or `no clone URLs provided` when the list is empty.
|
/// or `no clone URLs provided` when the list is empty.
|
||||||
fn try_each_url<F>(urls: &[String], verb: &str, mut attempt: F) -> Result<()>
|
fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnMut(&str) -> Result<()>,
|
F: FnMut(&str) -> Result<()>,
|
||||||
{
|
{
|
||||||
let mut last_err: Option<anyhow::Error> = None;
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
|
||||||
for url in urls {
|
for url in urls {
|
||||||
match attempt(url) {
|
match attempt(url.as_ref()) {
|
||||||
Ok(()) => return Ok(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => last_err = Some(e),
|
Err(e) => last_err = Some(e),
|
||||||
}
|
}
|
||||||
@@ -698,7 +704,7 @@ fn edit_local_config(
|
|||||||
/// When no URL works, the last error is returned.
|
/// When no URL works, the last error is returned.
|
||||||
///
|
///
|
||||||
/// Never touches the checked-out refs or the worktree.
|
/// Never touches the checked-out refs or the worktree.
|
||||||
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
|
pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
|
||||||
let repo = gix::open(repo_path)?;
|
let repo = gix::open(repo_path)?;
|
||||||
let refspec = gix::refspec::parse(
|
let refspec = gix::refspec::parse(
|
||||||
gix::bstr::BStr::new(refspec),
|
gix::bstr::BStr::new(refspec),
|
||||||
@@ -1057,6 +1063,15 @@ pub fn sanitize_path_component(id: &str) -> String {
|
|||||||
sanitized
|
sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The refs namespace of a fork's import in the target mirror.
|
||||||
|
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
announcement.owner.to_hex(),
|
||||||
|
sanitize_path_component(&announcement.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// In-memory object cache for history walks, see [`open_with_cache`].
|
/// 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.
|
/// Without one, a walk re-decodes the same commit objects from the object database.
|
||||||
@@ -1583,24 +1598,145 @@ fn tree_diff(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `git format-patch` output, a single patch or a series.
|
/// 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> {
|
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||||
let lines: Vec<&str> = patch.lines().collect();
|
// `PatchSet` reports an error when the input holds no patch at all,
|
||||||
let mut files = Vec::new();
|
// while a patch without git diff sections is simply empty here.
|
||||||
let mut i = 0;
|
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||||
|
return Ok(CommitDiff { files: Vec::new() });
|
||||||
|
}
|
||||||
|
|
||||||
while i < lines.len() {
|
let mut files = Vec::new();
|
||||||
let Some(header) = lines[i].strip_prefix("diff --git ") else {
|
|
||||||
i += 1;
|
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||||
continue;
|
files.push(file_diff(file?)?);
|
||||||
};
|
|
||||||
let (file, next) = parse_diff_section(header, &lines, i + 1)?;
|
|
||||||
files.push(file);
|
|
||||||
i = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CommitDiff { files })
|
Ok(CommitDiff { files })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The [`FileDiff`] of one parsed file patch.
|
||||||
|
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.
|
/// 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.
|
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||||
@@ -1682,277 +1818,6 @@ fn strip_patch_prefix(subject: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one file's diff section.
|
|
||||||
///
|
|
||||||
/// Returns the section and the index of the first unconsumed line.
|
|
||||||
fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> {
|
|
||||||
let (header_old, header_new) = header_paths(header)?;
|
|
||||||
// The `---` and `+++` lines name the two sides unambiguously.
|
|
||||||
// The `diff --git` header cannot distinguish spaces in paths.
|
|
||||||
// Fall back to the header for sections without them, pure renames and mode changes.
|
|
||||||
let mut old_path = header_old;
|
|
||||||
let mut new_path = header_new;
|
|
||||||
|
|
||||||
let mut status = DiffStatus::Modified;
|
|
||||||
let mut binary = false;
|
|
||||||
let mut hunks = Vec::new();
|
|
||||||
let mut insertions = 0usize;
|
|
||||||
let mut deletions = 0usize;
|
|
||||||
let mut i = start;
|
|
||||||
|
|
||||||
while i < lines.len() {
|
|
||||||
let line = lines[i];
|
|
||||||
|
|
||||||
// The next file's section starts at this line.
|
|
||||||
if line.starts_with("diff --git ") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
|
|
||||||
if line.starts_with("@@ -") {
|
|
||||||
let (hunk, next) = parse_hunk(lines, i - 1)?;
|
|
||||||
i = next;
|
|
||||||
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);
|
|
||||||
} else if let Some(rest) = line.strip_prefix("--- ") {
|
|
||||||
if rest == "/dev/null" {
|
|
||||||
status = DiffStatus::Added;
|
|
||||||
} else {
|
|
||||||
old_path = diff_line_path(rest, "a/")?;
|
|
||||||
}
|
|
||||||
} else if let Some(rest) = line.strip_prefix("+++ ") {
|
|
||||||
if rest == "/dev/null" {
|
|
||||||
status = DiffStatus::Deleted;
|
|
||||||
} else {
|
|
||||||
new_path = diff_line_path(rest, "b/")?;
|
|
||||||
}
|
|
||||||
} else if line.starts_with("new file mode ") {
|
|
||||||
status = DiffStatus::Added;
|
|
||||||
} else if line.starts_with("deleted file mode ") {
|
|
||||||
status = DiffStatus::Deleted;
|
|
||||||
} else if line.starts_with("copy from ") {
|
|
||||||
status = DiffStatus::Copied;
|
|
||||||
} else if line.starts_with("rename from ") {
|
|
||||||
status = DiffStatus::Renamed;
|
|
||||||
} else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
|
|
||||||
binary = true;
|
|
||||||
// A literal binary patch may follow.
|
|
||||||
// Skip it without consuming the next section's header.
|
|
||||||
while i < lines.len() && !lines[i].starts_with("diff --git ") {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Everything else, index, mode and similarity lines, is ignored.
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
FileDiff {
|
|
||||||
path: new_path,
|
|
||||||
old_path: matches!(status, DiffStatus::Renamed | DiffStatus::Copied)
|
|
||||||
.then_some(old_path),
|
|
||||||
status,
|
|
||||||
insertions,
|
|
||||||
deletions,
|
|
||||||
binary,
|
|
||||||
hunks,
|
|
||||||
},
|
|
||||||
i,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse one hunk, the `@@ -a,b +c,d @@` header plus every body line.
|
|
||||||
///
|
|
||||||
/// Returns the hunk and the index of the first unconsumed line.
|
|
||||||
fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> {
|
|
||||||
let (old_start, old_lines, new_start, new_lines) = hunk_header(lines[start])?;
|
|
||||||
|
|
||||||
let mut diff_lines = Vec::new();
|
|
||||||
let mut old = old_start;
|
|
||||||
let mut new = new_start;
|
|
||||||
let mut i = start + 1;
|
|
||||||
|
|
||||||
while i < lines.len() {
|
|
||||||
let line = lines[i];
|
|
||||||
let Some(kind) = line_prefix_kind(line) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
i += 1;
|
|
||||||
|
|
||||||
// Context lines advance both counters.
|
|
||||||
// Deletions advance only the old counter, additions only the new one.
|
|
||||||
// Every line then carries its real number in both versions.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
diff_lines.push(DiffLine {
|
|
||||||
kind,
|
|
||||||
old: old_no,
|
|
||||||
new: new_no,
|
|
||||||
text: line[1..].to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
DiffHunk {
|
|
||||||
old_start,
|
|
||||||
old_lines,
|
|
||||||
new_start,
|
|
||||||
new_lines,
|
|
||||||
lines: diff_lines,
|
|
||||||
},
|
|
||||||
i,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The kind of a hunk body line, from its first character.
|
|
||||||
///
|
|
||||||
/// Lines outside a hunk, headers, `\ No newline...` and the next section, yield `None`.
|
|
||||||
fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
|
|
||||||
match line.as_bytes().first()? {
|
|
||||||
b' ' => Some(DiffLineKind::Context),
|
|
||||||
b'+' => Some(DiffLineKind::Addition),
|
|
||||||
b'-' => Some(DiffLineKind::Deletion),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a unified-diff hunk header, `@@ -a,b +c,d @@`.
|
|
||||||
///
|
|
||||||
/// Omitted line counts default to 1.
|
|
||||||
fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> {
|
|
||||||
let rest = header
|
|
||||||
.strip_prefix("@@ ")
|
|
||||||
.context("malformed hunk header")?;
|
|
||||||
let (old_spec, rest) = rest.split_once(' ').context("malformed hunk header")?;
|
|
||||||
let new_spec = rest.split_once(' ').map(|(new, _)| new).unwrap_or(rest);
|
|
||||||
|
|
||||||
fn parse(spec: &str) -> Result<(u32, u32)> {
|
|
||||||
let spec = spec.strip_prefix(['-', '+']).unwrap_or(spec);
|
|
||||||
let (start, count) = match spec.split_once(',') {
|
|
||||||
Some((start, count)) => (start, count.parse::<u32>()?),
|
|
||||||
None => (spec, 1),
|
|
||||||
};
|
|
||||||
Ok((start.parse::<u32>()?, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
let (old_start, old_lines) = parse(old_spec)?;
|
|
||||||
let (new_start, new_lines) = parse(new_spec)?;
|
|
||||||
Ok((old_start, old_lines, new_start, new_lines))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The old and new paths of a `diff --git a/X b/Y` header.
|
|
||||||
fn header_paths(header: &str) -> Result<(String, String)> {
|
|
||||||
if header.starts_with('"') {
|
|
||||||
// Quoted paths include the `a/` / `b/` prefix inside the quotes.
|
|
||||||
let (old, rest) = take_quoted(header).context("unterminated quoted path")?;
|
|
||||||
let rest = rest.trim_start();
|
|
||||||
let new = if rest.starts_with('"') {
|
|
||||||
take_quoted(rest).context("unterminated quoted path")?.0
|
|
||||||
} else {
|
|
||||||
rest.split_whitespace().next().unwrap_or(rest)
|
|
||||||
};
|
|
||||||
let old = old
|
|
||||||
.strip_prefix("a/")
|
|
||||||
.context("old path without `a/` prefix")?;
|
|
||||||
let new = new
|
|
||||||
.strip_prefix("b/")
|
|
||||||
.context("new path without `b/` prefix")?;
|
|
||||||
Ok((unquote_path(old)?, unquote_path(new)?))
|
|
||||||
} else {
|
|
||||||
let (old, rest) = header
|
|
||||||
.rsplit_once(" b/")
|
|
||||||
.context("malformed diff --git header")?;
|
|
||||||
let old = old
|
|
||||||
.strip_prefix("a/")
|
|
||||||
.context("old path without `a/` prefix")?;
|
|
||||||
Ok((old.to_owned(), rest.to_owned()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path of a `--- a/X` or `+++ b/Y` line.
|
|
||||||
fn diff_line_path(line: &str, prefix: &str) -> Result<String> {
|
|
||||||
let line = line.trim_end_matches('\t');
|
|
||||||
if line.starts_with('"') {
|
|
||||||
let (path, _) = take_quoted(line).context("unterminated quoted path")?;
|
|
||||||
let path = path
|
|
||||||
.strip_prefix(prefix)
|
|
||||||
.context("diff line path without `a/` or `b/` prefix")?;
|
|
||||||
unquote_path(path)
|
|
||||||
} else {
|
|
||||||
Ok(line
|
|
||||||
.strip_prefix(prefix)
|
|
||||||
.context("diff line path without `a/` or `b/` prefix")?
|
|
||||||
.to_owned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The content of a git C-style quoted path and the rest of the input.
|
|
||||||
/// The path spans the opening `"`, escaped content and closing `"`.
|
|
||||||
///
|
|
||||||
/// `None` if unterminated.
|
|
||||||
fn take_quoted(input: &str) -> Option<(&str, &str)> {
|
|
||||||
let mut end = 1; // byte after the opening quote
|
|
||||||
let mut rest = &input[1..];
|
|
||||||
while let Some(ch) = rest.chars().next() {
|
|
||||||
let len = ch.len_utf8();
|
|
||||||
match ch {
|
|
||||||
'\\' => {
|
|
||||||
// Consume the escaped character too, it may be multi-byte.
|
|
||||||
let escaped = rest[len..].chars().next()?;
|
|
||||||
let consumed = len + escaped.len_utf8();
|
|
||||||
end += consumed;
|
|
||||||
rest = &rest[consumed..];
|
|
||||||
}
|
|
||||||
'"' => return Some((&input[1..end], &input[end + len..])),
|
|
||||||
_ => {
|
|
||||||
end += len;
|
|
||||||
rest = &rest[len..];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Undo git's C-style path quoting, `\NNN` octal escapes, `\"` and `\\`.
|
|
||||||
///
|
|
||||||
/// Delegates to gitoxide's C-style quote implementation, `gix::quote::ansi_c::undo`.
|
|
||||||
/// It expects the surrounding double quotes, which are re-added around the interior.
|
|
||||||
fn unquote_path(path: &str) -> Result<String> {
|
|
||||||
if !path.contains('\\') {
|
|
||||||
return Ok(path.to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
let quoted = format!("\"{path}\"");
|
|
||||||
let (unquoted, _) = gix::quote::ansi_c::undo(gix::bstr::BStr::new(quoted.as_bytes()))
|
|
||||||
.map_err(|e| anyhow::anyhow!("malformed quoted path: {e}"))?;
|
|
||||||
String::from_utf8(unquoted.into_owned().to_vec()).context("invalid UTF-8 in quoted path")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||||
struct HunkCollector<'a> {
|
struct HunkCollector<'a> {
|
||||||
hunks: &'a mut Vec<DiffHunk>,
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
@@ -2374,6 +2239,21 @@ mod tests {
|
|||||||
assert_eq!(sanitize_path_component("a/../b"), "a_.._b");
|
assert_eq!(sanitize_path_component("a/../b"), "a_.._b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fork_namespace_combines_owner_and_sanitized_id() {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||||
|
.tags([Tag::parse(["d", "my/repo"]).expect("valid tag")])
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed event");
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fork_namespace(&announcement),
|
||||||
|
format!("{}/my_repo", keys.public_key().to_hex())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repo_path_stays_inside_root() {
|
fn repo_path_stays_inside_root() {
|
||||||
let cache = GitCache::new("/cache".into());
|
let cache = GitCache::new("/cache".into());
|
||||||
@@ -2950,9 +2830,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn working_copy_cloned_from_the_mirror_matches_head_and_origin() {
|
fn working_copy_cloned_from_the_mirror_matches_head_and_origin() {
|
||||||
// The mirror is a freshly initialized repository.
|
// The mirror is a freshly initialized repository, standing in for
|
||||||
// Its `origin` points at the grasp server.
|
// the grasp server. Its `origin` points at the (fake) grasp server.
|
||||||
// `Backend::create_repository` leaves it in the GitCache.
|
// `GitCache::ensure_clone` lazily clones from a URL shaped like this
|
||||||
|
// the first time a repository is opened.
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mirror = dir.path().join("mirror");
|
let mirror = dir.path().join("mirror");
|
||||||
let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init");
|
let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init");
|
||||||
@@ -3166,7 +3047,8 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("failed to fetch"));
|
assert!(err.to_string().contains("failed to fetch"));
|
||||||
|
|
||||||
// Without any URL there is nothing to try.
|
// Without any URL there is nothing to try.
|
||||||
let err = fetch_repo_refs(dir, &[], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs");
|
let err = fetch_repo_refs(dir, &[] as &[String], "+refs/heads/*:refs/fork/x/*")
|
||||||
|
.expect_err("no URLs");
|
||||||
assert!(err.to_string().contains("no clone URLs"));
|
assert!(err.to_string().contains("no clone URLs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+301
-398
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,15 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use settings::{CheckoutRecord, SettingsStore};
|
use settings::{CheckoutRecord, SettingsStore};
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
use crate::local_repos::LocalReposStore;
|
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
use crate::repo_list::RepoListStore;
|
use crate::repos::{LocalReposStore, RepoListStore};
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-computation.
|
/// Delay between a refresh request and the actual re-computation.
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
@@ -105,7 +104,6 @@ pub struct CheckoutsStore {
|
|||||||
/// The local pass runs a full pass again once this is older than the
|
/// The local pass runs a full pass again once this is older than the
|
||||||
/// reconciliation cadence, so remote moves still land.
|
/// reconciliation cadence, so remote moves still land.
|
||||||
last_full_sync: Option<Instant>,
|
last_full_sync: Option<Instant>,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +153,16 @@ impl CheckoutsStore {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut store = Self {
|
if !cfg!(target_arch = "wasm32") {
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
|
||||||
|
log::warn!("checkouts store dropped before initial refresh could run: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
by_repo: HashMap::new(),
|
by_repo: HashMap::new(),
|
||||||
statuses: HashMap::new(),
|
statuses: HashMap::new(),
|
||||||
status_requested: HashSet::new(),
|
status_requested: HashSet::new(),
|
||||||
@@ -165,23 +172,8 @@ impl CheckoutsStore {
|
|||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
local_pending: false,
|
local_pending: false,
|
||||||
last_full_sync: None,
|
last_full_sync: None,
|
||||||
tasks: Vec::new(),
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
|
||||||
|
|
||||||
if !cfg!(target_arch = "wasm32") {
|
|
||||||
store.refresh(cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
store
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track a spawned task, pruning finished tasks first.
|
|
||||||
///
|
|
||||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
|
||||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
|
||||||
self.tasks.retain(|task| !task.is_ready());
|
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remember a successful local-checkout use.
|
/// Remember a successful local-checkout use.
|
||||||
@@ -302,12 +294,11 @@ impl CheckoutsStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = 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();
|
||||||
self.push_task(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One full resolve and apply cycle, the debounced entry point.
|
/// One full resolve and apply cycle, the debounced entry point.
|
||||||
@@ -388,7 +379,7 @@ impl CheckoutsStore {
|
|||||||
Ok::<_, Error>((associations, statuses, push_statuses))
|
Ok::<_, Error>((associations, statuses, push_statuses))
|
||||||
});
|
});
|
||||||
|
|
||||||
self.push_task(cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let (associations, statuses, push_statuses) = match work.await {
|
let (associations, statuses, push_statuses) = match work.await {
|
||||||
Ok(results) => results,
|
Ok(results) => results,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -435,7 +426,8 @@ impl CheckoutsStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule the fast local status pass, unless one is already pending.
|
/// Schedule the fast local status pass, unless one is already pending.
|
||||||
@@ -449,15 +441,14 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
self.local_pending = true;
|
self.local_pending = true;
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(LOCAL_POLL).await;
|
cx.background_executor().timer(LOCAL_POLL).await;
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.local_pending = false;
|
this.local_pending = false;
|
||||||
this.local_tick(cx);
|
this.local_tick(cx);
|
||||||
})
|
})
|
||||||
});
|
})
|
||||||
|
.detach();
|
||||||
self.push_task(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fast local status pass.
|
/// The fast local status pass.
|
||||||
@@ -525,7 +516,7 @@ impl CheckoutsStore {
|
|||||||
Ok::<_, Error>((statuses, push_statuses))
|
Ok::<_, Error>((statuses, push_statuses))
|
||||||
});
|
});
|
||||||
|
|
||||||
self.push_task(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.
|
// Git reads are best-effort, keep the last results.
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -550,7 +541,8 @@ impl CheckoutsStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
mod checkouts;
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
mod local_repos;
|
|
||||||
mod profile;
|
mod profile;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
mod repo_list;
|
mod repos;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -13,11 +12,10 @@ pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
|||||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||||
pub use git_store::GitStore;
|
pub use git_store::GitStore;
|
||||||
use gpui::{App, AppContext, Entity};
|
use gpui::{App, AppContext, Entity};
|
||||||
pub use local_repos::LocalReposStore;
|
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
|
|
||||||
/// Initialize the backend and stores, and install them as globals.
|
/// Initialize the backend and stores, and install them as globals.
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
|
||||||
use signed_git::find_git_repos;
|
|
||||||
|
|
||||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
|
||||||
|
|
||||||
impl Global for GlobalLocalReposStore {}
|
|
||||||
|
|
||||||
/// Store of the git repositories discovered under a set of scan paths.
|
|
||||||
pub struct LocalReposStore {
|
|
||||||
/// The directories being scanned.
|
|
||||||
pub roots: Arc<Vec<PathBuf>>,
|
|
||||||
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
|
||||||
pub repos: Arc<Vec<PathBuf>>,
|
|
||||||
/// A scan is currently running.
|
|
||||||
pub scanning: bool,
|
|
||||||
/// A scan was requested while one was already running.
|
|
||||||
scan_dirty: bool,
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalReposStore {
|
|
||||||
/// Retrieve the global local-repositories store.
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalLocalReposStore>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalLocalReposStore(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a store scanning `roots` right away.
|
|
||||||
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
|
||||||
let mut store = Self {
|
|
||||||
roots: Arc::new(roots),
|
|
||||||
repos: Arc::new(Vec::new()),
|
|
||||||
scanning: false,
|
|
||||||
scan_dirty: false,
|
|
||||||
tasks: Vec::new(),
|
|
||||||
};
|
|
||||||
store.rescan(cx);
|
|
||||||
store
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forget a repository that has just been published to NIP-34.
|
|
||||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
|
||||||
self.repos = Arc::new(
|
|
||||||
self.repos
|
|
||||||
.iter()
|
|
||||||
.filter(|repo| repo.as_path() != path)
|
|
||||||
.cloned()
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-run the scan.
|
|
||||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.scanning {
|
|
||||||
self.scan_dirty = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.roots.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.scanning = true;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
let roots = self.roots.clone();
|
|
||||||
let work = cx.background_spawn(async move {
|
|
||||||
let mut repos = Vec::new();
|
|
||||||
for root in roots.iter() {
|
|
||||||
repos.extend(find_git_repos(root));
|
|
||||||
}
|
|
||||||
repos.sort();
|
|
||||||
repos.dedup();
|
|
||||||
repos
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
let repos = work.await;
|
|
||||||
let again = this.update(cx, |this, cx| {
|
|
||||||
this.repos = Arc::new(repos);
|
|
||||||
this.scanning = false;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
let dirty = this.scan_dirty;
|
|
||||||
this.scan_dirty = false;
|
|
||||||
dirty
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
|
||||||
if again {
|
|
||||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,7 +75,6 @@ pub struct ProfileStore {
|
|||||||
seen: RefCell<HashSet<PublicKey>>,
|
seen: RefCell<HashSet<PublicKey>>,
|
||||||
/// Sender for queuing fetch requests, batched by a background task.
|
/// Sender for queuing fetch requests, batched by a background task.
|
||||||
sender: Sender<PublicKey>,
|
sender: Sender<PublicKey>,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +96,13 @@ impl ProfileStore {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||||
BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => {
|
BackendEvent::NostrUpdate(updates) => {
|
||||||
this.apply_author(update.author, cx);
|
for update in updates
|
||||||
|
.iter()
|
||||||
|
.filter(|update| update.kind == Kind::Metadata)
|
||||||
|
{
|
||||||
|
this.apply_author(update.author, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
@@ -114,30 +118,24 @@ impl ProfileStore {
|
|||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
|
|
||||||
let mut tasks = Vec::new();
|
cx.spawn(async move |_this, cx| {
|
||||||
|
|
||||||
tasks.push(cx.spawn(async move |_this, cx| {
|
|
||||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
let mut store = Self {
|
let weak = cx.entity().downgrade();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
||||||
|
log::warn!("profile store dropped before initial load could run: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
profiles: HashMap::new(),
|
profiles: HashMap::new(),
|
||||||
seen: RefCell::new(HashSet::new()),
|
seen: RefCell::new(HashSet::new()),
|
||||||
sender,
|
sender,
|
||||||
tasks,
|
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
};
|
}
|
||||||
|
|
||||||
store.load(cx);
|
|
||||||
store
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track a spawned task, pruning finished tasks first.
|
|
||||||
///
|
|
||||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
|
||||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
|
||||||
self.tasks.retain(|task| !task.is_ready());
|
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a profile.
|
/// Get a profile.
|
||||||
@@ -181,7 +179,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
self.push_task(cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -192,7 +190,8 @@ impl ProfileStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of an author from the local database.
|
/// Re-read the latest metadata of an author from the local database.
|
||||||
@@ -217,7 +216,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profile)
|
Ok::<_, Error>(profile)
|
||||||
});
|
});
|
||||||
|
|
||||||
self.push_task(cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let profile = work.await?;
|
let profile = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -228,7 +227,8 @@ impl ProfileStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local database.
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
@@ -273,7 +273,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
self.push_task(cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -284,7 +284,8 @@ impl ProfileStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||||
@@ -337,7 +338,7 @@ impl ProfileStore {
|
|||||||
// Re-apply from the database afterwards.
|
// Re-apply from the database afterwards.
|
||||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _ = this.update(cx, |this, cx| this.apply_seen(cx));
|
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||||
}
|
}
|
||||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
Err(e) => log::warn!("profile sync failed: {e}"),
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-68
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Error, bail};
|
||||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||||
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
||||||
use nostr::event::IntoEventBuilder;
|
use nostr::event::IntoEventBuilder;
|
||||||
@@ -14,12 +14,13 @@ use signed_core::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::backend::{
|
use crate::backend::{
|
||||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
|
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
|
||||||
|
user_grasp_list_servers,
|
||||||
};
|
};
|
||||||
use crate::checkouts::CheckoutsStore;
|
use crate::checkouts::CheckoutsStore;
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
use crate::repo_list::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-query.
|
/// Delay between a refresh request and the actual re-query.
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
@@ -81,7 +82,6 @@ pub struct RepoStore {
|
|||||||
root_fetches: HashSet<EventId>,
|
root_fetches: HashSet<EventId>,
|
||||||
/// Refresh coalescing, see [`RefreshGate`].
|
/// Refresh coalescing, see [`RefreshGate`].
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ impl RepoStore {
|
|||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(update) => {
|
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||||
// Deletions may target any event of this repository.
|
// Deletions may target any event of this repository.
|
||||||
let deletion =
|
let deletion =
|
||||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||||
@@ -107,7 +107,7 @@ impl RepoStore {
|
|||||||
let status = RepoStatus::from_kind(update.kind).is_some();
|
let status = RepoStatus::from_kind(update.kind).is_some();
|
||||||
|
|
||||||
deletion || coordinate || (author && kind) || comment || status
|
deletion || coordinate || (author && kind) || comment || status
|
||||||
}
|
}),
|
||||||
BackendEvent::Published(event) => {
|
BackendEvent::Published(event) => {
|
||||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||||
let author = event.pubkey == this.addr.public_key;
|
let author = event.pubkey == this.addr.public_key;
|
||||||
@@ -127,7 +127,20 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut store = Self {
|
let weak = cx.entity().downgrade();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
let result = weak.update(cx, |this, cx| {
|
||||||
|
this.subscribe_remote(cx);
|
||||||
|
this.connect_announced_relays(&announced_relays, cx);
|
||||||
|
this.refresh(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
log::warn!("repo store dropped before bootstrap could run: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
addr,
|
addr,
|
||||||
announcement: None,
|
announcement: None,
|
||||||
head: None,
|
head: None,
|
||||||
@@ -148,16 +161,7 @@ impl RepoStore {
|
|||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
tasks: Vec::new(),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
store.subscribe_remote(cx);
|
|
||||||
// The announcement we opened the repo from may already list its relays.
|
|
||||||
// Connect to them right away.
|
|
||||||
// Do not wait for the bootstrap fetch to return the same event.
|
|
||||||
store.connect_announced_relays(&announced_relays, cx);
|
|
||||||
store.refresh(cx);
|
|
||||||
store
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the repository's address.
|
/// Returns the repository's address.
|
||||||
@@ -229,14 +233,12 @@ impl RepoStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = 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();
|
||||||
self.tasks.retain(|task| !task.is_ready());
|
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -372,9 +374,7 @@ impl RepoStore {
|
|||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.retain(|task| !task.is_ready());
|
cx.spawn(async move |this, cx| {
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
let (
|
let (
|
||||||
announcement,
|
announcement,
|
||||||
state,
|
state,
|
||||||
@@ -466,7 +466,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
||||||
@@ -514,7 +515,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.into_event_builder();
|
.into_event_builder();
|
||||||
|
|
||||||
self.send(builder, cx);
|
self.publish(builder, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comments on a root event, an issue or PR, oldest first.
|
/// Comments on a root event, an issue or PR, oldest first.
|
||||||
@@ -545,7 +546,7 @@ impl RepoStore {
|
|||||||
.and_then(|a| a.relays.first())
|
.and_then(|a| a.relays.first())
|
||||||
.cloned();
|
.cloned();
|
||||||
|
|
||||||
self.send(
|
self.publish(
|
||||||
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
|
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -638,7 +639,7 @@ impl RepoStore {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
// The PR references the root patch event.
|
// The PR references the root patch event.
|
||||||
// Viewers can then find the patch without carrying it inline.
|
// Viewers can then find the patch without carrying it inline.
|
||||||
let root_patch = match publish_patch_series(
|
let root_patch = match publish_patch_series(
|
||||||
@@ -798,12 +799,15 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let publish_task = this.update(cx, |_this, cx| {
|
let client = this.update(cx, |_this, cx| Backend::global(cx).read(cx).client())?;
|
||||||
let backend = Backend::global(cx);
|
|
||||||
backend.update(cx, |backend, cx| backend.publish_event(event, cx))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let pr_event = match publish_task.await {
|
let publish_result: Result<Event, Error> = async {
|
||||||
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
|
require_relay_accepted(output, event)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let pr_event = match publish_result {
|
||||||
Ok(event) => event,
|
Ok(event) => event,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return this.update(cx, |this, cx| {
|
return this.update(cx, |this, cx| {
|
||||||
@@ -813,6 +817,11 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx)
|
||||||
|
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
|
||||||
|
})?;
|
||||||
|
|
||||||
// A draft PR carries a kind-1633 status event, NIP-34.
|
// A draft PR carries a kind-1633 status event, NIP-34.
|
||||||
// Publish it right after the PR event so viewers never show it open.
|
// Publish it right after the PR event so viewers never show it open.
|
||||||
if draft {
|
if draft {
|
||||||
@@ -822,7 +831,61 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
|
||||||
|
/// then open a pull request from it.
|
||||||
|
///
|
||||||
|
/// Fails descriptively when there are no commits to propose or the patch
|
||||||
|
/// could not be generated; otherwise publishes exactly like
|
||||||
|
/// [`Self::open_pull_request`].
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn open_pull_request_from_refs(
|
||||||
|
&mut self,
|
||||||
|
repo_path: PathBuf,
|
||||||
|
merge_base: String,
|
||||||
|
compare_ref: String,
|
||||||
|
subject: Option<String>,
|
||||||
|
description: String,
|
||||||
|
branch_name: Option<String>,
|
||||||
|
draft: bool,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<(), Error>> {
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
// Regenerate the series at submit time.
|
||||||
|
// The published patch covers the current tip of the compare branch.
|
||||||
|
let patch = cx
|
||||||
|
.background_spawn({
|
||||||
|
let repo_path = repo_path.clone();
|
||||||
|
let merge_base = merge_base.clone();
|
||||||
|
let compare_ref = compare_ref.clone();
|
||||||
|
async move {
|
||||||
|
signed_git::format_patch_between(&repo_path, &merge_base, &compare_ref)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let patch = match patch {
|
||||||
|
Ok(patch) if !patch.is_empty() => patch,
|
||||||
|
Ok(_) => bail!("No commits between the branches to propose"),
|
||||||
|
Err(error) => bail!("Failed to generate the patch: {error}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.open_pull_request(
|
||||||
|
subject,
|
||||||
|
description,
|
||||||
|
branch_name,
|
||||||
|
patch,
|
||||||
|
draft,
|
||||||
|
Some(merge_base),
|
||||||
|
Some(repo_path),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a pull request.
|
/// Update a pull request.
|
||||||
@@ -894,7 +957,7 @@ impl RepoStore {
|
|||||||
.map(|a| a.clone.clone())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = publish_patch_series(
|
if let Err(e) = publish_patch_series(
|
||||||
&this,
|
&this,
|
||||||
cx,
|
cx,
|
||||||
@@ -913,7 +976,7 @@ impl RepoStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let update_task = this.update(cx, |this, cx| {
|
let builder = this.update(cx, |this, _cx| {
|
||||||
let builder = GitPullRequestUpdate {
|
let builder = GitPullRequestUpdate {
|
||||||
repository: this.addr.clone(),
|
repository: this.addr.clone(),
|
||||||
pull_request_event: root.id,
|
pull_request_event: root.id,
|
||||||
@@ -926,24 +989,44 @@ impl RepoStore {
|
|||||||
|
|
||||||
// The `r` EUC tag lets clients subscribe to all PR updates.
|
// The `r` EUC tag lets clients subscribe to all PR updates.
|
||||||
// The SDK builder omits it.
|
// The SDK builder omits it.
|
||||||
let builder = match euc.as_deref() {
|
match euc.as_deref() {
|
||||||
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
|
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
|
||||||
None => builder,
|
None => builder,
|
||||||
};
|
}
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
backend.update(cx, |backend, cx| backend.send(builder, cx))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Err(e) = update_task.await {
|
let (client, signer) = this.update(cx, |_this, cx| {
|
||||||
return this.update(cx, |this, cx| {
|
let backend = Backend::global(cx);
|
||||||
this.last_error = Some(e.to_string());
|
let backend = backend.read(cx);
|
||||||
cx.notify();
|
(backend.client(), backend.signer())
|
||||||
});
|
})?;
|
||||||
|
|
||||||
|
let publish_result: Result<Event, Error> = async {
|
||||||
|
let event = builder.finalize_async(&signer).await?;
|
||||||
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
|
require_relay_accepted(output, event)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match publish_result {
|
||||||
|
Ok(event) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx).update(cx, |backend, cx| {
|
||||||
|
backend.announce_published(event.clone(), cx)
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return this.update(cx, |this, cx| {
|
||||||
|
this.last_error = Some(e.to_string());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the status of a root event.
|
/// Set the status of a root event.
|
||||||
@@ -982,7 +1065,7 @@ impl RepoStore {
|
|||||||
Tag::coordinate(self.addr.clone(), None),
|
Tag::coordinate(self.addr.clone(), None),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self.send(builder, cx);
|
self.publish(builder, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge a pull request.
|
/// Merge a pull request.
|
||||||
@@ -1002,10 +1085,10 @@ impl RepoStore {
|
|||||||
let cache = GitStore::global(cx).cache().clone();
|
let cache = GitStore::global(cx).cache().clone();
|
||||||
let addr = self.addr.clone();
|
let addr = self.addr.clone();
|
||||||
|
|
||||||
let clone_urls: Vec<String> = self
|
let clone_urls: Vec<Url> = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let patch = pull_request_patch(root, self.patches.iter());
|
let patch = pull_request_patch(root, self.patches.iter());
|
||||||
@@ -1040,7 +1123,7 @@ impl RepoStore {
|
|||||||
Ok::<_, Error>(applied)
|
Ok::<_, Error>(applied)
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
match apply.await {
|
match apply.await {
|
||||||
Ok(applied) => {
|
Ok(applied) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -1062,7 +1145,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest announcement of this repository,
|
/// The latest announcement of this repository,
|
||||||
@@ -1224,7 +1308,7 @@ impl RepoStore {
|
|||||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||||
};
|
};
|
||||||
|
|
||||||
let clone_urls: Vec<String> = announcement.clone.iter().map(ToString::to_string).collect();
|
let clone_urls = announcement.clone.clone();
|
||||||
let addr = self.addr.clone();
|
let addr = self.addr.clone();
|
||||||
|
|
||||||
self.cloning = true;
|
self.cloning = true;
|
||||||
@@ -1328,24 +1412,50 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.send(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
|
self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
/// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`].
|
||||||
|
///
|
||||||
|
/// Every one-shot repository event (issue, comment, status) goes through
|
||||||
|
/// this. Multi-step flows (opening or updating a pull request, a patch
|
||||||
|
/// series) call the SDK directly instead, since their error handling and
|
||||||
|
/// post-conditions differ per step.
|
||||||
|
fn publish(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let task = backend.update(cx, |backend, cx| backend.send(builder, cx));
|
let (client, signer) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.client(), backend.signer())
|
||||||
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = task.await {
|
let publish_result: Result<Event, Error> = async {
|
||||||
this.update(cx, |this, cx| {
|
let event = builder.finalize_async(&signer).await?;
|
||||||
this.last_error = Some(e.to_string());
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
cx.notify();
|
require_relay_accepted(output, event)
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match publish_result {
|
||||||
|
Ok(event) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx)
|
||||||
|
.update(cx, |backend, cx| backend.announce_published(event, cx))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.last_error = Some(e.to_string());
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,6 +1536,12 @@ async fn publish_patch_series(
|
|||||||
first_marker: &str,
|
first_marker: &str,
|
||||||
reply_to: Option<EventId>,
|
reply_to: Option<EventId>,
|
||||||
) -> Result<Event, Error> {
|
) -> Result<Event, Error> {
|
||||||
|
let (client, signer) = this.update(cx, |_this, cx| {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.client(), backend.signer())
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut root: Option<Event> = None;
|
let mut root: Option<Event> = None;
|
||||||
let mut previous = reply_to;
|
let mut previous = reply_to;
|
||||||
|
|
||||||
@@ -1466,11 +1582,14 @@ async fn publish_patch_series(
|
|||||||
|
|
||||||
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
||||||
|
|
||||||
let task = this.update(cx, |_this, cx| {
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let backend = Backend::global(cx);
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
backend.update(cx, |backend, cx| backend.send(builder, cx))
|
let event = require_relay_accepted(output, event)?;
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx).update(cx, |backend, cx| {
|
||||||
|
backend.announce_published(event.clone(), cx)
|
||||||
|
})
|
||||||
})?;
|
})?;
|
||||||
let event = task.await?;
|
|
||||||
|
|
||||||
if root.is_none() {
|
if root.is_none() {
|
||||||
root = Some(event.clone());
|
root = Some(event.clone());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -6,10 +7,116 @@ use anyhow::Error;
|
|||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||||
|
use signed_git::find_git_repos;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
|
|
||||||
|
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||||
|
|
||||||
|
impl Global for GlobalLocalReposStore {}
|
||||||
|
|
||||||
|
/// Store of the git repositories discovered under a set of scan paths.
|
||||||
|
pub struct LocalReposStore {
|
||||||
|
/// The directories being scanned.
|
||||||
|
pub roots: Arc<Vec<PathBuf>>,
|
||||||
|
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
||||||
|
pub repos: Arc<Vec<PathBuf>>,
|
||||||
|
/// A scan is currently running.
|
||||||
|
pub scanning: bool,
|
||||||
|
/// A scan was requested while one was already running.
|
||||||
|
scan_dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalReposStore {
|
||||||
|
/// Retrieve the global local-repositories store.
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalLocalReposStore>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalLocalReposStore(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a store scanning `roots` right away.
|
||||||
|
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
|
||||||
|
log::warn!("local repos store dropped before initial scan could run: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
roots: Arc::new(roots),
|
||||||
|
repos: Arc::new(Vec::new()),
|
||||||
|
scanning: false,
|
||||||
|
scan_dirty: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a repository that has just been published to NIP-34.
|
||||||
|
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||||
|
self.repos = Arc::new(
|
||||||
|
self.repos
|
||||||
|
.iter()
|
||||||
|
.filter(|repo| repo.as_path() != path)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-run the scan.
|
||||||
|
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.scanning {
|
||||||
|
self.scan_dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.roots.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.scanning = true;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let roots = self.roots.clone();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move {
|
||||||
|
let mut repos = Vec::new();
|
||||||
|
for root in roots.iter() {
|
||||||
|
repos.extend(find_git_repos(root));
|
||||||
|
}
|
||||||
|
repos.sort();
|
||||||
|
repos.dedup();
|
||||||
|
repos
|
||||||
|
});
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
let repos = work.await;
|
||||||
|
let again = this.update(cx, |this, cx| {
|
||||||
|
this.repos = Arc::new(repos);
|
||||||
|
this.scanning = false;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let dirty = this.scan_dirty;
|
||||||
|
this.scan_dirty = false;
|
||||||
|
dirty
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||||
|
if again {
|
||||||
|
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-query.
|
/// Delay between a refresh request and the actual re-query.
|
||||||
///
|
///
|
||||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||||
@@ -55,7 +162,6 @@ pub struct RepoListStore {
|
|||||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||||
/// Refresh coalescing, see [`RefreshGate`].
|
/// Refresh coalescing, see [`RefreshGate`].
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +181,7 @@ impl RepoListStore {
|
|||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(update) => {
|
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||||
// Deletions may target anything we list, always refresh.
|
// Deletions may target anything we list, always refresh.
|
||||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||||
true
|
true
|
||||||
@@ -88,7 +194,7 @@ impl RepoListStore {
|
|||||||
let is_repo_state = update.kind == Kind::RepoState;
|
let is_repo_state = update.kind == Kind::RepoState;
|
||||||
is_announcement || is_repo_state
|
is_announcement || is_repo_state
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
BackendEvent::Published(event) => {
|
BackendEvent::Published(event) => {
|
||||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||||
|
|
||||||
@@ -99,7 +205,10 @@ impl RepoListStore {
|
|||||||
|
|
||||||
announcement || deletion
|
announcement || deletion
|
||||||
}
|
}
|
||||||
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
// Only a completed sync refreshes the list.
|
||||||
|
// Progress ticks would re-scan the whole database several times
|
||||||
|
// per sync to reveal entries incrementally.
|
||||||
|
BackendEvent::Synced => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,20 +217,26 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut store = Self {
|
let weak = cx.entity().downgrade();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
let result = weak.update(cx, |this, cx| {
|
||||||
|
this.subscribe_remote(cx);
|
||||||
|
// Query the local database right away.
|
||||||
|
// The list never waits for the relay syncs started above to finish.
|
||||||
|
this.refresh_initial(cx);
|
||||||
|
});
|
||||||
|
if let Err(error) = result {
|
||||||
|
log::warn!("repo list store dropped before bootstrap could run: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
announcements: Arc::new(Vec::new()),
|
announcements: Arc::new(Vec::new()),
|
||||||
last_activity: Arc::new(HashMap::new()),
|
last_activity: Arc::new(HashMap::new()),
|
||||||
counts: Arc::new(HashMap::new()),
|
counts: Arc::new(HashMap::new()),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
tasks: Vec::new(),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
store.subscribe_remote(cx);
|
|
||||||
// Query the local database right away.
|
|
||||||
// The list never waits for the relay syncs started above to finish.
|
|
||||||
store.refresh_initial(cx);
|
|
||||||
store
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The announcements of `user`, newest first.
|
/// The announcements of `user`, newest first.
|
||||||
@@ -133,14 +248,6 @@ impl RepoListStore {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a spawned task, pruning finished tasks first.
|
|
||||||
///
|
|
||||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
|
||||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
|
||||||
self.tasks.retain(|task| !task.is_ready());
|
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Negentropy-sync announcements with the bootstrap relays.
|
/// Negentropy-sync announcements with the bootstrap relays.
|
||||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
@@ -171,13 +278,11 @@ impl RepoListStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = 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();
|
||||||
self.push_task(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One query and apply cycle, the debounced entry point.
|
/// One query and apply cycle, the debounced entry point.
|
||||||
@@ -294,7 +399,7 @@ impl RepoListStore {
|
|||||||
Ok::<_, Error>((announcements, last_activity, counts))
|
Ok::<_, Error>((announcements, last_activity, counts))
|
||||||
});
|
});
|
||||||
|
|
||||||
self.push_task(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.
|
// Database errors are transient, keep the last list.
|
||||||
@@ -321,6 +426,7 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,8 +325,6 @@ pub struct CommitDiffView {
|
|||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
/// In-flight tasks, pruned on every push.
|
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommitDiffView {
|
impl CommitDiffView {
|
||||||
@@ -358,7 +356,6 @@ impl CommitDiffView {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
pane,
|
pane,
|
||||||
tasks: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,42 +368,43 @@ impl CommitDiffView {
|
|||||||
let worktree = self.worktree.clone();
|
let worktree = self.worktree.clone();
|
||||||
let id = self.commit.id.clone();
|
let id = self.commit.id.clone();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
let commit = cx
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
.background_spawn({
|
let commit = cx
|
||||||
let worktree = worktree.clone();
|
.background_spawn({
|
||||||
let id = id.clone();
|
let worktree = worktree.clone();
|
||||||
async move { signed_git::worktree_commit(&worktree, &id) }
|
let id = id.clone();
|
||||||
})
|
async move { signed_git::worktree_commit(&worktree, &id) }
|
||||||
.await;
|
})
|
||||||
let diff = cx
|
.await;
|
||||||
.background_spawn({
|
let diff = cx
|
||||||
let worktree = worktree.clone();
|
.background_spawn({
|
||||||
let id = id.clone();
|
let worktree = worktree.clone();
|
||||||
async move { signed_git::worktree_commit_diff(&worktree, &id) }
|
let id = id.clone();
|
||||||
})
|
async move { signed_git::worktree_commit_diff(&worktree, &id) }
|
||||||
.await;
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
if let Ok(Some(commit)) = commit {
|
if let Ok(Some(commit)) = commit {
|
||||||
this.commit = commit;
|
this.commit = commit;
|
||||||
}
|
|
||||||
match diff {
|
|
||||||
Ok(diff) => {
|
|
||||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
match diff {
|
||||||
this.error = Some(error.to_string().into());
|
Ok(diff) => {
|
||||||
|
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.error = Some(error.to_string().into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
cx.notify();
|
||||||
cx.notify();
|
})?;
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header with the commit id, summary, author/time and overall change stats.
|
/// Header with the commit id, summary, author/time and overall change stats.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ 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,
|
||||||
Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task,
|
Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity,
|
||||||
WeakEntity, Window, div, px, relative, size, transparent_white,
|
Window, div, px, relative, size, transparent_white,
|
||||||
};
|
};
|
||||||
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||||
use gpui_component::alert::Alert;
|
use gpui_component::alert::Alert;
|
||||||
@@ -24,7 +24,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
|
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
|
||||||
VirtualListScrollHandle, h_flex, v_flex,
|
VirtualListScrollHandle, h_flex, v_flex,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{RelayUrl, ToBech32};
|
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
||||||
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
|
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
|
||||||
use signed_git::{CommitList, FileCommit};
|
use signed_git::{CommitList, FileCommit};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
@@ -175,9 +175,6 @@ pub struct RepoDetailView {
|
|||||||
/// Bumped on every branch/tag switch.
|
/// Bumped on every branch/tag switch.
|
||||||
/// In-flight loads with an older generation are discarded when they complete.
|
/// In-flight loads with an older generation are discarded when they complete.
|
||||||
ref_generation: u64,
|
ref_generation: u64,
|
||||||
/// In-flight tasks, finished tasks are pruned on every push.
|
|
||||||
/// The vec stays bounded by the number of concurrent loads.
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
/// Subscriptions keeping the selectors' confirm events alive.
|
/// Subscriptions keeping the selectors' confirm events alive.
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
|
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
|
||||||
@@ -346,7 +343,6 @@ impl RepoDetailView {
|
|||||||
push_statuses: Vec::new(),
|
push_statuses: Vec::new(),
|
||||||
pending_upstream: None,
|
pending_upstream: None,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
tasks: Vec::new(),
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,7 +359,7 @@ impl RepoDetailView {
|
|||||||
// Local repositories live on disk at their scan path.
|
// Local repositories live on disk at their scan path.
|
||||||
// No clone step or network refresh applies here.
|
// No clone step or network refresh applies here.
|
||||||
if let Some(local_path) = self.local_path.clone() {
|
if let Some(local_path) = self.local_path.clone() {
|
||||||
let task = 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 = gix::open(&local_path)?;
|
||||||
@@ -383,7 +379,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -394,7 +390,7 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
let cache = GitStore::global(cx).cache().clone();
|
||||||
let addr = initial.addr();
|
let addr = initial.addr();
|
||||||
let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect();
|
let clone_urls: Vec<Url> = initial.clone.clone();
|
||||||
|
|
||||||
// Captured before the loads start.
|
// Captured before the loads start.
|
||||||
// A branch/tag switch bumps the generation, discarding the refresh below.
|
// A branch/tag switch bumps the generation, discarding the refresh below.
|
||||||
@@ -411,7 +407,7 @@ impl RepoDetailView {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
let disk = disk.await;
|
let disk = disk.await;
|
||||||
let had_clone = matches!(&disk, Ok(Some(_)));
|
let had_clone = matches!(&disk, Ok(Some(_)));
|
||||||
|
|
||||||
@@ -531,7 +527,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the loaded repository data.
|
/// Apply the loaded repository data.
|
||||||
@@ -619,7 +615,7 @@ impl RepoDetailView {
|
|||||||
prompt: Some("Clone".into()),
|
prompt: Some("Clone".into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
// `Ok(Ok(Some(paths)))` means the user picked a folder.
|
// `Ok(Ok(Some(paths)))` means the user picked a folder.
|
||||||
// A cancel or picker failure resolves to anything else.
|
// A cancel or picker failure resolves to anything else.
|
||||||
let picked = match prompt.await {
|
let picked = match prompt.await {
|
||||||
@@ -649,7 +645,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preview the file at `path`, relative to the worktree root.
|
/// Preview the file at `path`, relative to the worktree root.
|
||||||
@@ -703,7 +699,7 @@ impl RepoDetailView {
|
|||||||
self.load_commit(&path, cx);
|
self.load_commit(&path, cx);
|
||||||
let generation = self.ref_generation;
|
let generation = self.ref_generation;
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
let path_for_read = path.clone();
|
let path_for_read = path.clone();
|
||||||
let content = cx
|
let content = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
@@ -772,7 +768,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue `path` for the per-file commit query.
|
/// Queue `path` for the per-file commit query.
|
||||||
@@ -804,7 +800,7 @@ impl RepoDetailView {
|
|||||||
let paths = std::mem::take(&mut self.pending_commits);
|
let paths = std::mem::take(&mut self.pending_commits);
|
||||||
let generation = self.ref_generation;
|
let generation = self.ref_generation;
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
let task: gpui::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(
|
||||||
@@ -834,7 +830,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk all commits reachable from HEAD on a background task.
|
/// Walk all commits reachable from HEAD on a background task.
|
||||||
@@ -852,7 +848,7 @@ impl RepoDetailView {
|
|||||||
self.loading_all_commits = true;
|
self.loading_all_commits = true;
|
||||||
let generation = self.ref_generation;
|
let generation = self.ref_generation;
|
||||||
|
|
||||||
let task = 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 { signed_git::worktree_all_commits(&worktree) })
|
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
|
||||||
.await;
|
.await;
|
||||||
@@ -876,7 +872,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a new panel showing the diff of `commit_id`.
|
/// Open a new panel showing the diff of `commit_id`.
|
||||||
@@ -909,8 +905,9 @@ impl RepoDetailView {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
self.tasks
|
store
|
||||||
.push(store.update(cx, |store, cx| store.push_repository(cx)));
|
.update(cx, |store, cx| store.push_repository(cx))
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the unpushed commits of the local checkout at `path`.
|
/// Push the unpushed commits of the local checkout at `path`.
|
||||||
@@ -931,7 +928,7 @@ impl RepoDetailView {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
// The store owns the push, its busy flag and error reporting.
|
// The store owns the push, its busy flag and error reporting.
|
||||||
let push = this.update_in(cx, |_this, _window, cx| {
|
let push = this.update_in(cx, |_this, _window, cx| {
|
||||||
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
|
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
|
||||||
@@ -948,7 +945,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the repository from nostr, announcement, state and activity.
|
/// Delete the repository from nostr, announcement, state and activity.
|
||||||
@@ -956,8 +953,9 @@ impl RepoDetailView {
|
|||||||
let Some(store) = self.store.clone() else {
|
let Some(store) = self.store.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.tasks
|
store
|
||||||
.push(store.update(cx, |store, cx| store.delete_repository(cx)));
|
.update(cx, |store, cx| store.delete_repository(cx))
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the issues list panel in the dock area.
|
/// Open the issues list panel in the dock area.
|
||||||
@@ -1025,7 +1023,7 @@ impl RepoDetailView {
|
|||||||
});
|
});
|
||||||
self.pending_upstream = Some(addr);
|
self.pending_upstream = Some(addr);
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
for _ in 0..60 {
|
for _ in 0..60 {
|
||||||
cx.background_executor()
|
cx.background_executor()
|
||||||
.timer(Duration::from_millis(250))
|
.timer(Duration::from_millis(250))
|
||||||
@@ -1060,7 +1058,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check out `name`, a branch or tag picked in the header.
|
/// Check out `name`, a branch or tag picked in the header.
|
||||||
@@ -1101,7 +1099,7 @@ impl RepoDetailView {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let checkout_name = name.clone();
|
let checkout_name = name.clone();
|
||||||
let task = 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 {
|
||||||
match kind {
|
match kind {
|
||||||
@@ -1131,7 +1129,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore a selector to `previous`, or clear it after a failed switch.
|
/// Restore a selector to `previous`, or clear it after a failed switch.
|
||||||
@@ -1157,7 +1155,7 @@ impl RepoDetailView {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let task = 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 = signed_git::worktree_snapshot(&worktree)?;
|
||||||
@@ -1218,7 +1216,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh the file explorer, previews and commit list after the mirror
|
/// Refresh the file explorer, previews and commit list after the mirror
|
||||||
@@ -1233,7 +1231,7 @@ impl RepoDetailView {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let task = 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 = signed_git::worktree_snapshot(&worktree)?;
|
||||||
@@ -1314,7 +1312,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the cached preview, editor and commit state of `path`.
|
/// Drop the cached preview, editor and commit state of `path`.
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handl
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions,
|
||||||
Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative,
|
Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||||
size,
|
|
||||||
};
|
};
|
||||||
use gpui_base::{Button as BaseButton, StyledExt};
|
use gpui_base::{Button as BaseButton, StyledExt};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
@@ -22,10 +21,10 @@ use gpui_component::{
|
|||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr, fork_candidates};
|
||||||
use signed_git::{
|
use signed_git::{
|
||||||
delete_refs_with_prefix, fetch_repo_refs, format_patch_between, merge_base, refs_with_prefix,
|
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
|
||||||
sanitize_path_component, worktree_commit_range_commits, worktree_commit_range_diff,
|
worktree_commit_range_commits, worktree_commit_range_diff,
|
||||||
};
|
};
|
||||||
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
|
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
|
||||||
use signed_ui::{CountBadge, placeholder};
|
use signed_ui::{CountBadge, placeholder};
|
||||||
@@ -79,7 +78,6 @@ pub struct NewPullRequestView {
|
|||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fork-backed compare.
|
/// A fork-backed compare.
|
||||||
@@ -104,36 +102,6 @@ impl ForkCompare {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The refs namespace of a fork's import in the target mirror.
|
|
||||||
fn fork_namespace(announcement: &Announcement) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/{}",
|
|
||||||
announcement.owner.to_hex(),
|
|
||||||
sanitize_path_component(&announcement.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The announced forks of `base` a New PR compare can be built from.
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The display name of an announcement.
|
/// The display name of an announcement.
|
||||||
///
|
///
|
||||||
/// Its human-readable name, falling back to the repository id.
|
/// Its human-readable name, falling back to the repository id.
|
||||||
@@ -363,7 +331,6 @@ impl NewPullRequestView {
|
|||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
tasks: Vec::new(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefill with the store's freshest associated checkout, no folder dialog.
|
// Prefill with the store's freshest associated checkout, no folder dialog.
|
||||||
@@ -426,25 +393,26 @@ impl NewPullRequestView {
|
|||||||
prompt: Some("Choose local checkout".into()),
|
prompt: Some("Choose local checkout".into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
// `Ok(Ok(Some(paths)))` means the user picked a folder.
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
// A cancel or picker failure resolves to anything else.
|
// `Ok(Ok(Some(paths)))` means the user picked a folder.
|
||||||
let picked = match prompt.await {
|
// A cancel or picker failure resolves to anything else.
|
||||||
Ok(Ok(Some(mut paths))) => paths.pop(),
|
let picked = match prompt.await {
|
||||||
_ => None,
|
Ok(Ok(Some(mut paths))) => paths.pop(),
|
||||||
};
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let Some(path) = picked else {
|
let Some(path) = picked else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.apply_folder_path(path, window, cx);
|
this.apply_folder_path(path, window, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply `path` as the local checkout, no picker.
|
/// Apply `path` as the local checkout, no picker.
|
||||||
@@ -453,28 +421,29 @@ impl NewPullRequestView {
|
|||||||
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let path = path.to_string_lossy().to_string();
|
let path = path.to_string_lossy().to_string();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
// Branches and the current branch are read off the UI thread.
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let info = cx
|
// Branches and the current branch are read off the UI thread.
|
||||||
.background_spawn({
|
let info = cx
|
||||||
let path = path.clone();
|
.background_spawn({
|
||||||
async move {
|
let path = path.clone();
|
||||||
let repo = gix::open(Path::new(&path)).ok()?;
|
async move {
|
||||||
let branches =
|
let repo = gix::open(Path::new(&path)).ok()?;
|
||||||
signed_git::worktree_branches(Path::new(&path)).unwrap_or_default();
|
let branches =
|
||||||
let current = signed_git::current_branch(&repo).ok().flatten();
|
signed_git::worktree_branches(Path::new(&path)).unwrap_or_default();
|
||||||
Some((branches, current))
|
let current = signed_git::current_branch(&repo).ok().flatten();
|
||||||
}
|
Some((branches, current))
|
||||||
})
|
}
|
||||||
.await;
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.apply_checkout(path, info, window, cx);
|
this.apply_checkout(path, info, window, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a picked checkout, filling the selectors and loading the compare.
|
/// Apply a picked checkout, filling the selectors and loading the compare.
|
||||||
@@ -592,14 +561,14 @@ impl NewPullRequestView {
|
|||||||
let cache = GitStore::global(cx).cache().clone();
|
let cache = GitStore::global(cx).cache().clone();
|
||||||
let mirror_path = cache.repo_path(&base);
|
let mirror_path = cache.repo_path(&base);
|
||||||
let namespace = fork_namespace(&announcement);
|
let namespace = fork_namespace(&announcement);
|
||||||
let clone_urls: Vec<String> = announcement.clone.iter().map(ToString::to_string).collect();
|
let clone_urls = announcement.clone.clone();
|
||||||
|
|
||||||
let base_clone_urls: Vec<String> = self
|
let base_clone_urls: Vec<Url> = self
|
||||||
.store
|
.store
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Keep the current compare and base when the fork is already applied.
|
// Keep the current compare and base when the fork is already applied.
|
||||||
@@ -615,88 +584,89 @@ impl NewPullRequestView {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
// The fork and base must share history for a merge-base to exist.
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
// The target's mirror is the object store both sides land in.
|
// The fork and base must share history for a merge-base to exist.
|
||||||
// `ensure_clone` fetches `origin` when the mirror already exists.
|
// The target's mirror is the object store both sides land in.
|
||||||
let result = cx
|
// `ensure_clone` fetches `origin` when the mirror already exists.
|
||||||
.background_spawn({
|
let result = cx
|
||||||
let cache = cache.clone();
|
.background_spawn({
|
||||||
let base = base.clone();
|
let cache = cache.clone();
|
||||||
let base_clone_urls = base_clone_urls.clone();
|
let base = base.clone();
|
||||||
let namespace = namespace.clone();
|
let base_clone_urls = base_clone_urls.clone();
|
||||||
let clone_urls = clone_urls.clone();
|
let namespace = namespace.clone();
|
||||||
let mirror_path = mirror_path.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
async move {
|
let mirror_path = mirror_path.clone();
|
||||||
// The fork and base must share history for a merge-base to exist.
|
async move {
|
||||||
// The target's mirror is the object store both sides land in.
|
// The fork and base must share history for a merge-base to exist.
|
||||||
// `ensure_clone` fetches `origin` when the mirror already exists.
|
// The target's mirror is the object store both sides land in.
|
||||||
cache.ensure_clone(&base, &base_clone_urls)?;
|
// `ensure_clone` fetches `origin` when the mirror already exists.
|
||||||
|
cache.ensure_clone(&base, &base_clone_urls)?;
|
||||||
|
|
||||||
// Prune stale imports of any fork.
|
// Prune stale imports of any fork.
|
||||||
// Then import this fork's heads under its namespace.
|
// Then import this fork's heads under its namespace.
|
||||||
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
|
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
|
||||||
|
|
||||||
fetch_repo_refs(
|
fetch_repo_refs(
|
||||||
&mirror_path,
|
&mirror_path,
|
||||||
&clone_urls,
|
&clone_urls,
|
||||||
&format!("+refs/heads/*:refs/fork/{namespace}/*"),
|
&format!("+refs/heads/*:refs/fork/{namespace}/*"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Both branch lists are short names, sorted like the checkout's.
|
// Both branch lists are short names, sorted like the checkout's.
|
||||||
let strip = |refs: Vec<String>, prefix: &str| {
|
let strip = |refs: Vec<String>, prefix: &str| {
|
||||||
let mut names: Vec<String> = refs
|
let mut names: Vec<String> = refs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|name| {
|
.filter_map(|name| {
|
||||||
name.strip_prefix(prefix)
|
name.strip_prefix(prefix)
|
||||||
.map(|rest| rest.trim_start_matches('/').to_owned())
|
.map(|rest| rest.trim_start_matches('/').to_owned())
|
||||||
})
|
})
|
||||||
.filter(|name| !name.is_empty())
|
.filter(|name| !name.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
names
|
names
|
||||||
};
|
};
|
||||||
|
|
||||||
let base_branches = strip(
|
let base_branches = strip(
|
||||||
refs_with_prefix(&mirror_path, "refs/remotes/origin")?,
|
refs_with_prefix(&mirror_path, "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}"))?,
|
refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?,
|
||||||
&format!("refs/fork/{namespace}"),
|
&format!("refs/fork/{namespace}"),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>((base_branches, compare_branches))
|
Ok::<_, anyhow::Error>((base_branches, compare_branches))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
// A source switch mid-flight discards the stale result.
|
||||||
|
// E.g. the user picked a folder while the fork was fetching.
|
||||||
|
let applied = this.fork.as_ref().map(|fork| fork.announcement.addr());
|
||||||
|
if applied != expected_fork {
|
||||||
|
this.loading = false;
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.apply_fork(
|
||||||
// A source switch mid-flight discards the stale result.
|
announcement,
|
||||||
// E.g. the user picked a folder while the fork was fetching.
|
mirror_path,
|
||||||
let applied = this.fork.as_ref().map(|fork| fork.announcement.addr());
|
namespace,
|
||||||
if applied != expected_fork {
|
result,
|
||||||
this.loading = false;
|
keep_base,
|
||||||
cx.notify();
|
keep_compare,
|
||||||
return;
|
window,
|
||||||
}
|
cx,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
|
||||||
this.apply_fork(
|
Ok(())
|
||||||
announcement,
|
});
|
||||||
mirror_path,
|
task.detach();
|
||||||
namespace,
|
|
||||||
result,
|
|
||||||
keep_base,
|
|
||||||
keep_compare,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an imported fork, filling the selectors and loading the compare.
|
/// Apply an imported fork, filling the selectors and loading the compare.
|
||||||
@@ -834,66 +804,68 @@ impl NewPullRequestView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
let result = cx
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
.background_spawn({
|
let result = cx
|
||||||
let repo_path = repo_path.clone();
|
.background_spawn({
|
||||||
let base = base.clone();
|
let repo_path = repo_path.clone();
|
||||||
let compare = compare.clone();
|
let base = base.clone();
|
||||||
let base_name = base_name.clone();
|
let compare = compare.clone();
|
||||||
let compare_name = compare_name.clone();
|
let base_name = base_name.clone();
|
||||||
async move {
|
let compare_name = compare_name.clone();
|
||||||
let merge_base = merge_base(Path::new(&repo_path), &base, &compare)?
|
async move {
|
||||||
.ok_or_else(|| {
|
let merge_base = merge_base(Path::new(&repo_path), &base, &compare)?
|
||||||
anyhow::anyhow!(
|
.ok_or_else(|| {
|
||||||
"{base_name} and {compare_name} share no common ancestor"
|
anyhow::anyhow!(
|
||||||
)
|
"{base_name} and {compare_name} share no common ancestor"
|
||||||
})?;
|
)
|
||||||
let commits = worktree_commit_range_commits(
|
})?;
|
||||||
Path::new(&repo_path),
|
let commits = worktree_commit_range_commits(
|
||||||
&merge_base,
|
Path::new(&repo_path),
|
||||||
&compare,
|
&merge_base,
|
||||||
)?;
|
&compare,
|
||||||
let diff = worktree_commit_range_diff(
|
)?;
|
||||||
Path::new(&repo_path),
|
let diff = worktree_commit_range_diff(
|
||||||
&merge_base,
|
Path::new(&repo_path),
|
||||||
&compare,
|
&merge_base,
|
||||||
)?;
|
&compare,
|
||||||
Ok::<_, anyhow::Error>((merge_base, commits, diff))
|
)?;
|
||||||
|
Ok::<_, anyhow::Error>((merge_base, commits, diff))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
// A stale result, branches changed mid-flight, must not clobber a newer compare.
|
||||||
|
if generation != this.compare_generation {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
})
|
this.loading = false;
|
||||||
.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
match result {
|
||||||
// A stale result, branches changed mid-flight, must not clobber a newer compare.
|
Ok((merge_base, commits, diff)) => {
|
||||||
if generation != this.compare_generation {
|
this.merge_base = Some(merge_base);
|
||||||
return;
|
let count = commits.len();
|
||||||
}
|
this.item_sizes =
|
||||||
this.loading = false;
|
Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
||||||
|
this.commits = Some(commits);
|
||||||
match result {
|
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||||
Ok((merge_base, commits, diff)) => {
|
}
|
||||||
this.merge_base = Some(merge_base);
|
Err(error) => {
|
||||||
let count = commits.len();
|
this.merge_base = None;
|
||||||
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
this.commits = None;
|
||||||
this.commits = Some(commits);
|
this.pane.update(cx, |pane, cx| pane.clear(cx));
|
||||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
this.error = Some(error.to_string().into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
|
||||||
this.merge_base = None;
|
|
||||||
this.commits = None;
|
|
||||||
this.pane.update(cx, |pane, cx| pane.clear(cx));
|
|
||||||
this.error = Some(error.to_string().into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish the pull request.
|
/// Publish the pull request.
|
||||||
@@ -927,76 +899,55 @@ impl NewPullRequestView {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
// Regenerate the series at submit time.
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
// The published patch covers the current tip of the compare branch.
|
// Regenerate the series at submit time.
|
||||||
let patch = cx
|
// The published patch covers the current tip of the compare branch.
|
||||||
.background_spawn({
|
let publish = store.update(cx, |store, cx| {
|
||||||
let repo_path = repo_path.clone();
|
store.open_pull_request_from_refs(
|
||||||
let merge_base = merge_base.clone();
|
repo_path,
|
||||||
let compare_ref = compare_ref.clone();
|
merge_base,
|
||||||
async move {
|
compare_ref,
|
||||||
format_patch_between(Path::new(&repo_path), &merge_base, &compare_ref)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let patch = match patch {
|
|
||||||
Ok(patch) if !patch.is_empty() => patch,
|
|
||||||
Ok(_) => {
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
this.submitting = false;
|
|
||||||
this.error = Some("No commits between the branches to propose".into());
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
this.submitting = false;
|
|
||||||
this.error = Some(format!("Failed to generate the patch: {error}").into());
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.submitting = false;
|
|
||||||
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.open_pull_request(
|
|
||||||
(!subject.is_empty()).then_some(subject),
|
(!subject.is_empty()).then_some(subject),
|
||||||
description,
|
description,
|
||||||
Some(branch_name),
|
Some(branch_name),
|
||||||
patch,
|
|
||||||
false,
|
false,
|
||||||
Some(merge_base),
|
|
||||||
Some(repo_path),
|
|
||||||
cx,
|
cx,
|
||||||
);
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close the panel once the publish is underway.
|
if let Err(error) = publish.await {
|
||||||
cx.defer_in(window, {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
let dock_area = dock_area.clone();
|
this.submitting = false;
|
||||||
let entity = entity.clone();
|
this.error = Some(error.to_string().into());
|
||||||
move |_, window, cx| {
|
cx.notify();
|
||||||
if let Some(dock_area) = dock_area.upgrade() {
|
})?;
|
||||||
dock_area.update(cx, |dock, cx| {
|
return Ok(());
|
||||||
dock.remove_panel(entity, window, cx);
|
}
|
||||||
});
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.submitting = false;
|
||||||
|
|
||||||
|
// Close the panel once the publish is underway.
|
||||||
|
cx.defer_in(window, {
|
||||||
|
let dock_area = dock_area.clone();
|
||||||
|
let entity = entity.clone();
|
||||||
|
move |_, window, cx| {
|
||||||
|
if let Some(dock_area) = dock_area.upgrade() {
|
||||||
|
dock_area.update(cx, |dock, cx| {
|
||||||
|
dock.remove_panel(entity, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the diff of `commit_id`, from the Commits tab, in a new panel.
|
/// Open the diff of `commit_id`, from the Commits tab, in a new panel.
|
||||||
@@ -1459,140 +1410,3 @@ impl Render for NewPullRequestView {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use nostr::prelude::*;
|
|
||||||
use signed_core::repo_addr;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const OWNER_KEYS: [&str; 3] = [
|
|
||||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
|
||||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Build a signed kind-30617 event for `owner` with the given tags.
|
|
||||||
fn 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 announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
|
|
||||||
vec![
|
|
||||||
Announcement::from_event(&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 = 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![
|
|
||||||
announcements(
|
|
||||||
2,
|
|
||||||
&[
|
|
||||||
&["d", "other-project"],
|
|
||||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
announcements(
|
|
||||||
1,
|
|
||||||
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
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));
|
|
||||||
|
|
||||||
// The user's fork comes first, then the other author's.
|
|
||||||
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 = repo_addr(base_owner, "upstream");
|
|
||||||
|
|
||||||
let mut all = vec![
|
|
||||||
announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
announcements(
|
|
||||||
2,
|
|
||||||
&[
|
|
||||||
&["d", "other"],
|
|
||||||
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
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(
|
|
||||||
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"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||||
SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
|
SharedString, Size, WeakEntity, Window, div, px, relative, size,
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::clipboard::Clipboard;
|
use gpui_component::clipboard::Clipboard;
|
||||||
@@ -20,8 +20,11 @@ use gpui_component::{
|
|||||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{Event, EventId, Kind, Nip34Tag};
|
use nostr::prelude::{Event, EventId, Kind};
|
||||||
use signed_core::{activity_subject, pull_request_patch};
|
use signed_core::{
|
||||||
|
activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
|
||||||
|
merge_base_of, pull_request_patch,
|
||||||
|
};
|
||||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||||
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||||
@@ -66,8 +69,6 @@ pub struct PullRequestDetailView {
|
|||||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// Virtual list state of the commits tab.
|
/// Virtual list state of the commits tab.
|
||||||
commit_scroll_handle: VirtualListScrollHandle,
|
commit_scroll_handle: VirtualListScrollHandle,
|
||||||
/// In-flight tasks, finished tasks are pruned on every push.
|
|
||||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PullRequestDetailView {
|
impl PullRequestDetailView {
|
||||||
@@ -106,7 +107,6 @@ impl PullRequestDetailView {
|
|||||||
pane,
|
pane,
|
||||||
commit_item_sizes: Rc::new(Vec::new()),
|
commit_item_sizes: Rc::new(Vec::new()),
|
||||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||||
tasks: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,12 +142,8 @@ impl PullRequestDetailView {
|
|||||||
.and_then(merge_base_of)
|
.and_then(merge_base_of)
|
||||||
.or_else(|| merge_base_of(root));
|
.or_else(|| merge_base_of(root));
|
||||||
|
|
||||||
let clone_urls = clone_urls_of(root).or_else(|| {
|
let clone_urls = clone_urls_of(root)
|
||||||
store
|
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()));
|
||||||
.announcement
|
|
||||||
.as_ref()
|
|
||||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
|
||||||
});
|
|
||||||
|
|
||||||
(
|
(
|
||||||
root.content.clone(),
|
root.content.clone(),
|
||||||
@@ -162,101 +158,103 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
self.description = description.into();
|
self.description = description.into();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
let nostr_diff = cx
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
.background_spawn({
|
let nostr_diff = cx
|
||||||
let patch = patch.clone();
|
.background_spawn({
|
||||||
async move { patch_diffs(&patch) }
|
let patch = patch.clone();
|
||||||
})
|
async move { patch_diffs(&patch) }
|
||||||
.await;
|
|
||||||
|
|
||||||
let nostr_commits = cx
|
|
||||||
.background_spawn({
|
|
||||||
let patch = patch.clone();
|
|
||||||
async move { patch_commits(&patch) }
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
|
||||||
// Fetch the clone and diff the `merge-base..tip` range.
|
|
||||||
let use_nostr = match &nostr_diff {
|
|
||||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
|
||||||
Err(_) => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let git = if use_nostr {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let cache = cache.clone();
|
|
||||||
let addr = addr.clone();
|
|
||||||
let clone_urls = clone_urls.clone();
|
|
||||||
let base = merge_base.clone();
|
|
||||||
let tip = current_commit.clone();
|
|
||||||
|
|
||||||
Some(
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
|
||||||
|
|
||||||
let workdir = repo
|
|
||||||
.workdir()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
let tip =
|
|
||||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
|
||||||
|
|
||||||
let base = match base {
|
|
||||||
Some(base) => base,
|
|
||||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
|
||||||
None => {
|
|
||||||
let head = repo
|
|
||||||
.head_id()
|
|
||||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
|
||||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
|
||||||
repo.merge_base(tip_id, head)?.to_string()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
|
||||||
let commits =
|
|
||||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
|
||||||
})
|
})
|
||||||
.await,
|
.await;
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (diff, commits, worktree) = match git {
|
let nostr_commits = cx
|
||||||
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
.background_spawn({
|
||||||
Some(Err(error)) => (Err(error), Vec::new(), None),
|
let patch = patch.clone();
|
||||||
None => (nostr_diff, nostr_commits, None),
|
async move { patch_commits(&patch) }
|
||||||
};
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||||
this.loading = false;
|
// Fetch the clone and diff the `merge-base..tip` range.
|
||||||
this.worktree = worktree;
|
let use_nostr = match &nostr_diff {
|
||||||
this.current_commit = current_commit.map(SharedString::from);
|
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||||
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
Err(_) => true,
|
||||||
this.commits = commits;
|
};
|
||||||
|
|
||||||
match diff {
|
let git = if use_nostr {
|
||||||
Ok(diff) => {
|
None
|
||||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
} else {
|
||||||
|
let cache = cache.clone();
|
||||||
|
let addr = addr.clone();
|
||||||
|
let clone_urls = clone_urls.clone();
|
||||||
|
let base = merge_base.clone();
|
||||||
|
let tip = current_commit.clone();
|
||||||
|
|
||||||
|
Some(
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||||
|
|
||||||
|
let workdir = repo
|
||||||
|
.workdir()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
let tip = tip
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||||
|
|
||||||
|
let base = match base {
|
||||||
|
Some(base) => base,
|
||||||
|
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||||
|
None => {
|
||||||
|
let head = repo
|
||||||
|
.head_id()
|
||||||
|
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||||
|
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||||
|
repo.merge_base(tip_id, head)?.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let diff =
|
||||||
|
signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||||
|
let commits =
|
||||||
|
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||||
|
|
||||||
|
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (diff, commits, worktree) = match git {
|
||||||
|
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||||
|
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||||
|
None => (nostr_diff, nostr_commits, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
this.loading = false;
|
||||||
|
this.worktree = worktree;
|
||||||
|
this.current_commit = current_commit.map(SharedString::from);
|
||||||
|
this.commit_item_sizes =
|
||||||
|
Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||||
|
this.commits = commits;
|
||||||
|
|
||||||
|
match diff {
|
||||||
|
Ok(diff) => {
|
||||||
|
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.error = Some(error.to_string().into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
|
||||||
this.error = Some(error.to_string().into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.retain(|task| !task.is_ready());
|
task.detach();
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||||
@@ -706,66 +704,6 @@ fn open_update_pull_request_dialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `c` tag of a PR event, the commit the proposal points at.
|
/// The `c` tag of a PR event, the commit the proposal points at.
|
||||||
fn current_commit_of(root: &Event) -> Option<String> {
|
|
||||||
root.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 a PR event, as hex.
|
|
||||||
///
|
|
||||||
/// The most recent common ancestor with the target branch.
|
|
||||||
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 a PR event.
|
|
||||||
///
|
|
||||||
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
|
|
||||||
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Clone(urls)) => Some(urls.iter().map(ToString::to_string).collect()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `branch-name` tag of a PR event, if any.
|
|
||||||
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 latest PR update, kind 1619, revising `root`.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-line commit metadata for the commits list.
|
/// One-line commit metadata for the commits list.
|
||||||
///
|
///
|
||||||
/// Author and relative time, whichever is available.
|
/// Author and relative time, whichever is available.
|
||||||
@@ -826,104 +764,9 @@ impl Render for PullRequestDetailView {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use nostr::prelude::{Tag, *};
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
|
||||||
|
|
||||||
fn keys() -> Keys {
|
|
||||||
Keys::new(
|
|
||||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
|
||||||
.expect("valid secret key"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a signed event with a controlled `created_at`.
|
|
||||||
fn signed(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(
|
|
||||||
Kind::GitPullRequest,
|
|
||||||
vec![
|
|
||||||
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
|
|
||||||
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
|
|
||||||
],
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_current_commit_and_branch_name() {
|
|
||||||
let pr = pr_root();
|
|
||||||
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
|
|
||||||
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_without_pr_tags() {
|
|
||||||
let pr = signed(Kind::GitPullRequest, vec![], 100);
|
|
||||||
assert_eq!(current_commit_of(&pr), None);
|
|
||||||
assert_eq!(branch_name_of(&pr), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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(
|
|
||||||
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(
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_update_ignores_roots_without_revisions() {
|
|
||||||
let root = pr_root();
|
|
||||||
assert!(latest_update([&root].into_iter(), &root).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_meta_combines_author_and_time() {
|
fn commit_meta_combines_author_and_time() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user