Compare commits
6
Commits
c2c2839ac1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38e8b2b933 | ||
|
|
a8e19e5fcd | ||
|
|
1af4c66566 | ||
|
|
02a0134164 | ||
|
|
cf1c9e2162 | ||
|
|
00167c6a8d |
@@ -0,0 +1,173 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: windows-x64
|
||||||
|
os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
- platform: windows-arm64
|
||||||
|
os: windows-11-arm
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
- platform: macos-x64
|
||||||
|
os: macos-15-intel
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
- platform: macos-arm64
|
||||||
|
os: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
- platform: linux-x64
|
||||||
|
os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
- platform: linux-arm64
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
# Windows and macOS builds using cargo-packager
|
||||||
|
- name: Build with cargo-packager (Windows/macOS)
|
||||||
|
if: runner.os != 'Linux'
|
||||||
|
working-directory: desktop
|
||||||
|
run: |
|
||||||
|
cargo install cargo-packager --locked
|
||||||
|
cargo packager --release
|
||||||
|
|
||||||
|
- name: Upload Windows/macOS artifacts
|
||||||
|
if: runner.os != 'Linux'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.platform }}-artifacts
|
||||||
|
path: |
|
||||||
|
dist/*.dmg
|
||||||
|
dist/*.msi
|
||||||
|
dist/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Linux builds using custom scripts
|
||||||
|
- name: Install Linux build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y flatpak flatpak-builder snapd squashfs-tools jq gettext-base
|
||||||
|
|
||||||
|
- name: Install Snapcraft
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo snap install snapcraft --classic
|
||||||
|
|
||||||
|
- name: Make scripts executable
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
chmod +x script/get-crate-version
|
||||||
|
chmod +x script/linux
|
||||||
|
chmod +x script/bundle-snap
|
||||||
|
chmod +x script/bundle-linux
|
||||||
|
chmod +x script/flatpak/deps
|
||||||
|
chmod +x script/flatpak/bundle-flatpak
|
||||||
|
|
||||||
|
- name: Install required dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: ./script/linux
|
||||||
|
|
||||||
|
# Build the release tarball on every Linux architecture
|
||||||
|
- name: Build release tarball
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: ./script/bundle-linux
|
||||||
|
|
||||||
|
# Only build Flatpak and Snap for x86_64 (most common use case)
|
||||||
|
- name: Build Flatpak
|
||||||
|
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
./script/flatpak/deps
|
||||||
|
./script/flatpak/bundle-flatpak
|
||||||
|
|
||||||
|
- name: Build Snap
|
||||||
|
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
VERSION=$(script/get-crate-version signed)
|
||||||
|
./script/bundle-snap $VERSION
|
||||||
|
|
||||||
|
- name: Collect Linux artifacts
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
mkdir -p linux-artifacts
|
||||||
|
# Copy the tarball created by bundle-linux
|
||||||
|
find target/release -name "*.tar.gz" -exec cp {} linux-artifacts/ \;
|
||||||
|
# Find and copy flatpak files (if they exist)
|
||||||
|
find . -name "*.flatpak" -exec cp {} linux-artifacts/ \; || true
|
||||||
|
# Find and copy snap files (if they exist)
|
||||||
|
find . -name "*.snap" -exec cp {} linux-artifacts/ \; || true
|
||||||
|
ls -la linux-artifacts/
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.platform }}-artifacts
|
||||||
|
path: linux-artifacts/**/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Make get-crate-version executable
|
||||||
|
run: chmod +x script/get-crate-version
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(script/get-crate-version signed)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Display artifacts structure
|
||||||
|
run: |
|
||||||
|
echo "Artifacts structure:"
|
||||||
|
find artifacts -type f -exec ls -la {} \;
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
id: create_release
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
server_url: "https://git.reya.su/"
|
||||||
|
repository: "reya/signed"
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
artifacts/**/*
|
||||||
|
|
||||||
|
- name: Output release info
|
||||||
|
run: |
|
||||||
|
echo "Created draft release: ${{ steps.create_release.outputs.url }}"
|
||||||
|
echo "Release ID: ${{ steps.create_release.outputs.id }}"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Rust
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["m**"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
rustup: [stable]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Linux build dependencies
|
||||||
|
run: chmod +x ./script/linux && ./script/linux
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --verbose
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --verbose
|
||||||
+20
-1
@@ -1 +1,20 @@
|
|||||||
/target
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
target/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Snap and Flatpak local build output
|
||||||
|
/snap
|
||||||
|
/su.reya.signed.json
|
||||||
|
/linux-artifacts
|
||||||
|
|
||||||
|
# Vendored dependencies + cargo config generated by script/prepare-flathub
|
||||||
|
.cargo/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -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
+175
-112
@@ -286,7 +286,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assets"
|
name = "assets"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -457,7 +457,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -729,7 +729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec"
|
checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitcoin-internals",
|
"bitcoin-internals",
|
||||||
"hex-conservative 1.2.0",
|
"hex-conservative 1.3.0",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -766,7 +766,7 @@ checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitcoin-consensus-encoding",
|
"bitcoin-consensus-encoding",
|
||||||
"bitcoin-internals",
|
"bitcoin-internals",
|
||||||
"hex-conservative 1.2.0",
|
"hex-conservative 1.3.0",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -914,7 +914,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1217,7 +1217,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "collections"
|
name = "collections"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui_util",
|
"gpui_util",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -1509,18 +1509,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.16"
|
version = "0.5.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
|
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.7"
|
version = "0.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-epoch",
|
"crossbeam-epoch",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
@@ -1528,27 +1528,27 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.20"
|
version = "0.9.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.13"
|
version = "0.3.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
|
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.22"
|
version = "0.8.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crunchy"
|
name = "crunchy"
|
||||||
@@ -1683,13 +1683,22 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_refineable"
|
name = "derive_refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"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"
|
||||||
@@ -1760,7 +1769,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1774,7 +1783,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dock"
|
name = "dock"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-base",
|
"gpui-base",
|
||||||
@@ -2097,9 +2106,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.11"
|
version = "0.1.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fixedbitset"
|
name = "fixedbitset"
|
||||||
@@ -2255,7 +2264,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2391,7 +2400,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2524,6 +2533,7 @@ dependencies = [
|
|||||||
"gix-credentials",
|
"gix-credentials",
|
||||||
"gix-date",
|
"gix-date",
|
||||||
"gix-diff",
|
"gix-diff",
|
||||||
|
"gix-dir",
|
||||||
"gix-discover",
|
"gix-discover",
|
||||||
"gix-error",
|
"gix-error",
|
||||||
"gix-features",
|
"gix-features",
|
||||||
@@ -2551,6 +2561,7 @@ dependencies = [
|
|||||||
"gix-revwalk",
|
"gix-revwalk",
|
||||||
"gix-sec",
|
"gix-sec",
|
||||||
"gix-shallow",
|
"gix-shallow",
|
||||||
|
"gix-status",
|
||||||
"gix-submodule",
|
"gix-submodule",
|
||||||
"gix-tempfile",
|
"gix-tempfile",
|
||||||
"gix-trace",
|
"gix-trace",
|
||||||
@@ -2710,13 +2721,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a"
|
checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bstr",
|
"bstr",
|
||||||
|
"gix-attributes",
|
||||||
"gix-command",
|
"gix-command",
|
||||||
"gix-filter",
|
"gix-filter",
|
||||||
"gix-fs",
|
"gix-fs",
|
||||||
"gix-hash",
|
"gix-hash",
|
||||||
"gix-imara-diff",
|
"gix-imara-diff",
|
||||||
|
"gix-index",
|
||||||
"gix-object",
|
"gix-object",
|
||||||
"gix-path",
|
"gix-path",
|
||||||
|
"gix-pathspec",
|
||||||
"gix-tempfile",
|
"gix-tempfile",
|
||||||
"gix-trace",
|
"gix-trace",
|
||||||
"gix-traverse",
|
"gix-traverse",
|
||||||
@@ -2724,6 +2738,26 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gix-dir"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8342d5eb0ea054ed6ef807e628e38f06dd51eceeec9529767d8b23e33eff9c09"
|
||||||
|
dependencies = [
|
||||||
|
"bstr",
|
||||||
|
"gix-discover",
|
||||||
|
"gix-fs",
|
||||||
|
"gix-ignore",
|
||||||
|
"gix-index",
|
||||||
|
"gix-object",
|
||||||
|
"gix-path",
|
||||||
|
"gix-pathspec",
|
||||||
|
"gix-trace",
|
||||||
|
"gix-utils",
|
||||||
|
"gix-worktree",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gix-discover"
|
name = "gix-discover"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
@@ -3182,6 +3216,31 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gix-status"
|
||||||
|
version = "0.34.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1401d871c01d82add5a9f654439b00722d0c632b332cd2cb63193f94327c8458"
|
||||||
|
dependencies = [
|
||||||
|
"bstr",
|
||||||
|
"filetime",
|
||||||
|
"gix-diff",
|
||||||
|
"gix-dir",
|
||||||
|
"gix-features",
|
||||||
|
"gix-filter",
|
||||||
|
"gix-fs",
|
||||||
|
"gix-hash",
|
||||||
|
"gix-index",
|
||||||
|
"gix-object",
|
||||||
|
"gix-path",
|
||||||
|
"gix-pathspec",
|
||||||
|
"gix-worktree",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"portable-atomic",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gix-submodule"
|
name = "gix-submodule"
|
||||||
version = "0.34.0"
|
version = "0.34.0"
|
||||||
@@ -3463,7 +3522,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui"
|
name = "gpui"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3535,7 +3594,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-base"
|
name = "gpui-base"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3571,7 +3630,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component"
|
name = "gpui-component"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3648,7 +3707,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component-assets"
|
name = "gpui-component-assets"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -3662,7 +3721,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component-macros"
|
name = "gpui-component-macros"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3672,21 +3731,21 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-fps"
|
name = "gpui-fps"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"instant",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-kit",
|
"objc2-io-kit",
|
||||||
"sysinfo 0.37.2",
|
"sysinfo 0.37.2",
|
||||||
"web-time",
|
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_apple"
|
name = "gpui_apple"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"block",
|
"block",
|
||||||
@@ -3709,7 +3768,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_linux"
|
name = "gpui_linux"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_unix",
|
"accesskit_unix",
|
||||||
@@ -3755,7 +3814,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macos"
|
name = "gpui_macos"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_macos",
|
"accesskit_macos",
|
||||||
@@ -3801,7 +3860,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macros"
|
name = "gpui_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3812,7 +3871,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_platform"
|
name = "gpui_platform"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -3825,7 +3884,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_shared_string"
|
name = "gpui_shared_string"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3835,7 +3894,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_util"
|
name = "gpui_util"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"log",
|
"log",
|
||||||
@@ -3845,7 +3904,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_web"
|
name = "gpui_web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
@@ -3869,7 +3928,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_wgpu"
|
name = "gpui_wgpu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
@@ -3895,7 +3954,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_windows"
|
name = "gpui_windows"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_windows",
|
"accesskit_windows",
|
||||||
@@ -4119,9 +4178,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex-conservative"
|
name = "hex-conservative"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10"
|
checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
]
|
]
|
||||||
@@ -4209,7 +4268,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client"
|
name = "http_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -4229,7 +4288,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client_tls"
|
name = "http_client_tls"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -4509,9 +4568,9 @@ checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.1"
|
version = "2.14.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
@@ -4614,9 +4673,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.1"
|
version = "2.12.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is-docker"
|
name = "is-docker"
|
||||||
@@ -4793,9 +4852,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.105"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -5079,9 +5138,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lyon_tessellation"
|
name = "lyon_tessellation"
|
||||||
version = "1.0.21"
|
version = "1.0.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dabea159dc6eea9171a541b458134b70ca95e3162d068132b2b3b9bc01aad06e"
|
checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"float_next_after",
|
"float_next_after",
|
||||||
"lyon_path",
|
"lyon_path",
|
||||||
@@ -5185,7 +5244,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "media"
|
name = "media"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -5410,7 +5469,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr"
|
name = "nostr"
|
||||||
version = "0.45.4"
|
version = "0.45.4"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -5436,7 +5495,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-connect"
|
name = "nostr-connect"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5450,7 +5509,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-database"
|
name = "nostr-database"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
"opaquerr",
|
"opaquerr",
|
||||||
@@ -5459,7 +5518,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip"
|
name = "nostr-gossip"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
"opaquerr",
|
"opaquerr",
|
||||||
@@ -5468,7 +5527,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip-memory"
|
name = "nostr-gossip-memory"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"lru",
|
"lru",
|
||||||
@@ -5480,7 +5539,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-lmdb"
|
name = "nostr-lmdb"
|
||||||
version = "0.45.2"
|
version = "0.45.2"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
@@ -5495,7 +5554,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-memory"
|
name = "nostr-memory"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"btreecap",
|
"btreecap",
|
||||||
"nostr",
|
"nostr",
|
||||||
@@ -5506,7 +5565,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-sdk"
|
name = "nostr-sdk"
|
||||||
version = "0.45.2"
|
version = "0.45.2"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
|
source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"async-wsocket",
|
"async-wsocket",
|
||||||
@@ -6209,7 +6268,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paths"
|
name = "paths"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
]
|
]
|
||||||
@@ -6243,7 +6302,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "perf"
|
name = "perf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"collections",
|
"collections",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6474,9 +6533,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic-util"
|
name = "portable-atomic-util"
|
||||||
version = "0.2.7"
|
version = "0.2.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
@@ -7025,13 +7084,13 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "refineable"
|
name = "refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"derive_refineable",
|
"derive_refineable",
|
||||||
]
|
]
|
||||||
@@ -7114,7 +7173,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest_client"
|
name = "reqwest_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -7492,7 +7551,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "scheduler"
|
name = "scheduler"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-task",
|
"async-task",
|
||||||
"backtrace",
|
"backtrace",
|
||||||
@@ -7528,7 +7587,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde_derive_internals",
|
"serde_derive_internals",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7699,7 +7758,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7710,7 +7769,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7744,7 +7803,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7779,7 +7838,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "settings"
|
name = "settings"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -7880,7 +7939,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signed"
|
name = "signed"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assets",
|
"assets",
|
||||||
"dock",
|
"dock",
|
||||||
@@ -7898,17 +7957,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signed_core"
|
name = "signed_core"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signed_git"
|
name = "signed_git"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"diffy",
|
||||||
"gix",
|
"gix",
|
||||||
|
"gix-worktree",
|
||||||
|
"gix-worktree-state",
|
||||||
|
"ignore",
|
||||||
"nostr",
|
"nostr",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -7916,7 +7979,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signed_nostr"
|
name = "signed_nostr"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
@@ -7929,7 +7992,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signed_state"
|
name = "signed_state"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitcoin_hashes 1.2.0",
|
"bitcoin_hashes 1.2.0",
|
||||||
@@ -7951,7 +8014,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signed_ui"
|
name = "signed_ui"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assets",
|
"assets",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -8201,7 +8264,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "sum_tree"
|
name = "sum_tree"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heapless 0.9.3",
|
"heapless 0.9.3",
|
||||||
"log",
|
"log",
|
||||||
@@ -8339,9 +8402,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.4"
|
version = "3.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -8569,7 +8632,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8661,9 +8724,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.12.0"
|
version = "1.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
|
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tinyvec_macros",
|
"tinyvec_macros",
|
||||||
]
|
]
|
||||||
@@ -8706,14 +8769,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -9599,7 +9662,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "util_macros"
|
name = "util_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"perf",
|
"perf",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -9608,7 +9671,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utils"
|
name = "utils"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
]
|
]
|
||||||
@@ -9756,9 +9819,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.127"
|
version = "0.2.128"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -9769,9 +9832,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.77"
|
version = "0.4.78"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
|
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -9779,9 +9842,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.127"
|
version = "0.2.128"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -9789,22 +9852,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.127"
|
version = "0.2.128"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.5",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.127"
|
version = "0.2.128"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -9934,9 +9997,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.105"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
|
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -10789,7 +10852,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "workspace"
|
name = "workspace"
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assets",
|
"assets",
|
||||||
@@ -11052,7 +11115,7 @@ dependencies = [
|
|||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
"zbus_names",
|
"zbus_names",
|
||||||
"zvariant",
|
"zvariant",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
@@ -11355,7 +11418,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11367,7 +11430,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zlog"
|
name = "zlog"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -11384,7 +11447,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing"
|
name = "ztracing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -11395,7 +11458,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing_macro"
|
name = "ztracing_macro"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
@@ -11461,7 +11524,7 @@ dependencies = [
|
|||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -11474,7 +11537,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
"syn 3.0.4",
|
"syn 3.0.5",
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+7
-8
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0"
|
version = "0.1.0-alpha"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -15,11 +15,10 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["fo
|
|||||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|
||||||
# `tree-sitter-languages` enables syntax highlighting for the TextView
|
# GPUI Kit
|
||||||
# code preview (fenced code blocks are highlighted with tree-sitter).
|
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
||||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] }
|
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||||
gpui-base = { git = "https://github.com/longbridge/gpui-component" }
|
gpui-fps = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||||
gpui-fps = { git = "https://github.com/longbridge/gpui-component" }
|
|
||||||
|
|
||||||
dock = { path = "crates/dock" }
|
dock = { path = "crates/dock" }
|
||||||
settings = { path = "crates/settings" }
|
settings = { path = "crates/settings" }
|
||||||
@@ -32,7 +31,7 @@ nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
|||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
|
||||||
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] }
|
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
|
||||||
|
|
||||||
smol = "2"
|
smol = "2"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
@@ -58,7 +57,7 @@ strip = true
|
|||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
panic = "unwind"
|
||||||
|
|
||||||
[profile.profiling]
|
[profile.profiling]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ signed_core = { path = "../signed_core" }
|
|||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||||
|
gix-worktree = "0.56"
|
||||||
|
gix-worktree-state = "0.34"
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
diffy = "0.5"
|
||||||
|
ignore = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
|
||||||
|
use crate::remote::{clone_repo, fetch_all};
|
||||||
|
|
||||||
|
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GitCache {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitCache {
|
||||||
|
pub fn new(root: PathBuf) -> Self {
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root directory holding the mirror clones.
|
||||||
|
pub fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local path of the clone for a repository.
|
||||||
|
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join(addr.public_key.to_hex())
|
||||||
|
.join(sanitize_path_component(&addr.identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an existing clone.
|
||||||
|
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||||
|
let path = self.repo_path(addr);
|
||||||
|
match gix::open(&path) {
|
||||||
|
Ok(repo) => Ok(Some(repo)),
|
||||||
|
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
|
||||||
|
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the existing clone, fetching it first.
|
||||||
|
pub fn ensure_clone<U: AsRef<str>>(
|
||||||
|
&self,
|
||||||
|
addr: &RepoAddr,
|
||||||
|
clone_urls: &[U],
|
||||||
|
) -> Result<gix::Repository> {
|
||||||
|
let path = self.repo_path(addr);
|
||||||
|
|
||||||
|
if let Some(repo) = self.open(addr)? {
|
||||||
|
fetch_all(&repo).ok();
|
||||||
|
return Ok(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone_repo(clone_urls, &path)?;
|
||||||
|
self.open(addr)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an untrusted repository id or display name to a safe single path component.
|
||||||
|
///
|
||||||
|
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
|
||||||
|
/// An id that maps to exactly `.` or `..` becomes `_`.
|
||||||
|
pub fn sanitize_path_component(id: &str) -> String {
|
||||||
|
let sanitized: String = id
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if sanitized == "." || sanitized == ".." {
|
||||||
|
return "_".to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||||
|
|
||||||
|
/// The kind of a [`DiffLine`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DiffLineKind {
|
||||||
|
/// An unchanged context line, present on both sides.
|
||||||
|
Context,
|
||||||
|
/// A line added by the commit.
|
||||||
|
Addition,
|
||||||
|
/// A line removed by the commit.
|
||||||
|
Deletion,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of a file diff.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiffLine {
|
||||||
|
pub kind: DiffLineKind,
|
||||||
|
/// 1-based line number in the old version, if the line exists there.
|
||||||
|
pub old: Option<u32>,
|
||||||
|
/// 1-based line number in the new version, if the line exists there.
|
||||||
|
pub new: Option<u32>,
|
||||||
|
/// Line content without the trailing newline.
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiffHunk {
|
||||||
|
/// 1-based start line in the old version.
|
||||||
|
pub old_start: u32,
|
||||||
|
/// Number of old lines covered by the hunk.
|
||||||
|
pub old_lines: u32,
|
||||||
|
/// 1-based start line in the new version.
|
||||||
|
pub new_start: u32,
|
||||||
|
/// Number of new lines covered by the hunk.
|
||||||
|
pub new_lines: u32,
|
||||||
|
pub lines: Vec<DiffLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a file changed in a commit.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DiffStatus {
|
||||||
|
Added,
|
||||||
|
Modified,
|
||||||
|
Deleted,
|
||||||
|
Renamed,
|
||||||
|
Copied,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The diff of one file in a commit.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileDiff {
|
||||||
|
/// Path of the file relative to the repo root.
|
||||||
|
///
|
||||||
|
/// For renames and copies, this is the destination path.
|
||||||
|
pub path: String,
|
||||||
|
/// Previous path, for renames and copies.
|
||||||
|
pub old_path: Option<String>,
|
||||||
|
pub status: DiffStatus,
|
||||||
|
/// Number of added lines, 0 for binary files.
|
||||||
|
pub insertions: usize,
|
||||||
|
/// Number of removed lines, 0 for binary files.
|
||||||
|
pub deletions: usize,
|
||||||
|
/// True if either version is binary, then `hunks` is empty.
|
||||||
|
pub binary: bool,
|
||||||
|
pub hunks: Vec<DiffHunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changes of one commit.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommitDiff {
|
||||||
|
pub files: Vec<FileDiff>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
|
||||||
|
///
|
||||||
|
/// Compared against its first parent, the empty tree for the root commit.
|
||||||
|
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||||
|
commit_diff(&gix::open(workdir)?, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||||
|
let commit_id = repo.rev_parse_single(id.as_bytes())?;
|
||||||
|
let commit = commit_id.object()?.into_commit();
|
||||||
|
let new_tree = commit.tree()?;
|
||||||
|
let old_tree = match commit.parent_ids().next() {
|
||||||
|
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
tree_diff(repo, old_tree.as_ref(), &new_tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changes between two commits, `base`..`tip`, like `git diff base tip`.
|
||||||
|
///
|
||||||
|
/// Directories and submodules are skipped, files are sorted by path.
|
||||||
|
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||||
|
let repo = gix::open(workdir)?;
|
||||||
|
let base_tree = repo
|
||||||
|
.rev_parse_single(base.as_bytes())?
|
||||||
|
.object()?
|
||||||
|
.into_commit()
|
||||||
|
.tree()?;
|
||||||
|
let tip_tree = repo
|
||||||
|
.rev_parse_single(tip.as_bytes())?
|
||||||
|
.object()?
|
||||||
|
.into_commit()
|
||||||
|
.tree()?;
|
||||||
|
tree_diff(&repo, Some(&base_tree), &tip_tree)
|
||||||
|
}
|
||||||
|
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
|
||||||
|
fn tree_diff(
|
||||||
|
repo: &gix::Repository,
|
||||||
|
old_tree: Option<&gix::Tree<'_>>,
|
||||||
|
new_tree: &gix::Tree<'_>,
|
||||||
|
) -> Result<CommitDiff> {
|
||||||
|
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||||
|
use gix::object::tree::diff::Change;
|
||||||
|
use gix::objs::tree::EntryKind;
|
||||||
|
|
||||||
|
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||||
|
|
||||||
|
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
for change in changes {
|
||||||
|
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
|
||||||
|
|
||||||
|
// Skip directory trees and submodule gitlinks, only files are listed.
|
||||||
|
let (path, old_path, status) = match attached {
|
||||||
|
Change::Addition {
|
||||||
|
location,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||||
|
(location.to_owned(), None, DiffStatus::Added)
|
||||||
|
}
|
||||||
|
Change::Deletion {
|
||||||
|
location,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||||
|
(location.to_owned(), None, DiffStatus::Deleted)
|
||||||
|
}
|
||||||
|
Change::Modification {
|
||||||
|
location,
|
||||||
|
previous_entry_mode,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||||
|
&& !matches!(
|
||||||
|
previous_entry_mode.kind(),
|
||||||
|
EntryKind::Tree | EntryKind::Commit
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
(location.to_owned(), None, DiffStatus::Modified)
|
||||||
|
}
|
||||||
|
Change::Rewrite {
|
||||||
|
location,
|
||||||
|
source_location,
|
||||||
|
source_entry_mode,
|
||||||
|
entry_mode,
|
||||||
|
copy,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||||
|
&& !matches!(
|
||||||
|
source_entry_mode.kind(),
|
||||||
|
EntryKind::Tree | EntryKind::Commit
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
let status = if copy {
|
||||||
|
DiffStatus::Copied
|
||||||
|
} else {
|
||||||
|
DiffStatus::Renamed
|
||||||
|
};
|
||||||
|
(
|
||||||
|
location.to_owned(),
|
||||||
|
Some(source_location.to_owned()),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always diff with the built-in algorithm.
|
||||||
|
// External diff drivers would shell out, out of scope for a read-only viewer.
|
||||||
|
let platform = attached.diff(&mut cache)?;
|
||||||
|
platform
|
||||||
|
.resource_cache
|
||||||
|
.options
|
||||||
|
.skip_internal_diff_if_external_is_configured = true;
|
||||||
|
let outcome = platform.resource_cache.prepare_diff()?;
|
||||||
|
|
||||||
|
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
||||||
|
Operation::InternalDiff { algorithm } => {
|
||||||
|
let input = outcome.interned_input();
|
||||||
|
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
||||||
|
|
||||||
|
let mut hunks = Vec::new();
|
||||||
|
let mut insertions = 0usize;
|
||||||
|
let mut deletions = 0usize;
|
||||||
|
let collector = HunkCollector {
|
||||||
|
hunks: &mut hunks,
|
||||||
|
insertions: &mut insertions,
|
||||||
|
deletions: &mut deletions,
|
||||||
|
};
|
||||||
|
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
||||||
|
.consume()?;
|
||||||
|
(false, hunks, insertions, deletions)
|
||||||
|
}
|
||||||
|
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
||||||
|
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
|
||||||
|
};
|
||||||
|
|
||||||
|
files.push(FileDiff {
|
||||||
|
path: String::from_utf8_lossy(&path).into_owned(),
|
||||||
|
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
||||||
|
status,
|
||||||
|
insertions,
|
||||||
|
deletions,
|
||||||
|
binary,
|
||||||
|
hunks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
|
||||||
|
Ok(CommitDiff { files })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||||
|
struct HunkCollector<'a> {
|
||||||
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
|
insertions: &'a mut usize,
|
||||||
|
deletions: &'a mut usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsumeHunk for HunkCollector<'_> {
|
||||||
|
type Out = ();
|
||||||
|
|
||||||
|
fn consume_hunk(
|
||||||
|
&mut self,
|
||||||
|
header: HunkHeader,
|
||||||
|
lines: &[(GixLineKind, &[u8])],
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let mut old_ln = header.before_hunk_start;
|
||||||
|
let mut new_ln = header.after_hunk_start;
|
||||||
|
let mut out = Vec::with_capacity(lines.len());
|
||||||
|
|
||||||
|
for (kind, content) in lines {
|
||||||
|
let text = String::from_utf8_lossy(content).into_owned();
|
||||||
|
let line = match kind {
|
||||||
|
GixLineKind::Context => {
|
||||||
|
let line = DiffLine {
|
||||||
|
kind: DiffLineKind::Context,
|
||||||
|
old: Some(old_ln),
|
||||||
|
new: Some(new_ln),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
old_ln += 1;
|
||||||
|
new_ln += 1;
|
||||||
|
line
|
||||||
|
}
|
||||||
|
GixLineKind::Remove => {
|
||||||
|
*self.deletions += 1;
|
||||||
|
let line = DiffLine {
|
||||||
|
kind: DiffLineKind::Deletion,
|
||||||
|
old: Some(old_ln),
|
||||||
|
new: None,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
old_ln += 1;
|
||||||
|
line
|
||||||
|
}
|
||||||
|
GixLineKind::Add => {
|
||||||
|
*self.insertions += 1;
|
||||||
|
let line = DiffLine {
|
||||||
|
kind: DiffLineKind::Addition,
|
||||||
|
old: None,
|
||||||
|
new: Some(new_ln),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
new_ln += 1;
|
||||||
|
line
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.hunks.push(DiffHunk {
|
||||||
|
old_start: header.before_hunk_start,
|
||||||
|
old_lines: header.before_hunk_len,
|
||||||
|
new_start: header.after_hunk_start,
|
||||||
|
new_lines: header.after_hunk_len,
|
||||||
|
lines: out,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// In-memory object cache for history walks, see [`open_with_cache`].
|
||||||
|
///
|
||||||
|
/// Without one, a walk re-decodes the same commit objects from the object database.
|
||||||
|
/// Sized generously: a walk can cover a large portion of the repository's history.
|
||||||
|
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Metadata of a commit, as shown in the repository browser's file header.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileCommit {
|
||||||
|
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
|
||||||
|
pub id: String,
|
||||||
|
/// First line of the commit message.
|
||||||
|
pub summary: String,
|
||||||
|
/// Rest of the commit message after the title.
|
||||||
|
///
|
||||||
|
/// `None` for single-line commit messages.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Author name.
|
||||||
|
pub author: String,
|
||||||
|
/// Author time, seconds since the Unix epoch.
|
||||||
|
pub time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the repository at `workdir` with an in-memory object cache.
|
||||||
|
///
|
||||||
|
/// Only history walks use it, they re-decode the same commit objects repeatedly.
|
||||||
|
/// Single-object reads open the repository plain.
|
||||||
|
pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
||||||
|
let mut repo = gix::open(workdir)?;
|
||||||
|
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||||
|
Ok(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`FileCommit`] from a commit, with author, message title, body and shortened id.
|
||||||
|
///
|
||||||
|
/// The diff panel fetches the full commit on demand.
|
||||||
|
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||||
|
file_commit_with_description(commit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`FileCommit`] without the message body, for history lists that never display it.
|
||||||
|
///
|
||||||
|
/// Skipping the body saves an allocation per listed commit.
|
||||||
|
fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||||
|
file_commit_with_description(commit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body.
|
||||||
|
fn file_commit_with_description(
|
||||||
|
commit: &gix::Commit<'_>,
|
||||||
|
include_description: bool,
|
||||||
|
) -> Result<FileCommit> {
|
||||||
|
let author = commit.author()?;
|
||||||
|
let message = commit.message()?;
|
||||||
|
|
||||||
|
Ok(FileCommit {
|
||||||
|
id: commit.id().shorten_or_id().to_string(),
|
||||||
|
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||||
|
description: if include_description {
|
||||||
|
message
|
||||||
|
.body
|
||||||
|
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||||
|
time: author.time()?.seconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` when no commit touched the file, e.g. an untracked file.
|
||||||
|
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||||
|
let rel = rel.to_path_buf();
|
||||||
|
Ok(last_commits(repo, std::slice::from_ref(&rel))?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|(_, commit)| commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||||
|
/// `rels` are paths relative to the worktree.
|
||||||
|
///
|
||||||
|
/// Paths without any commit, like untracked files, are absent from the result.
|
||||||
|
pub fn worktree_last_commits(
|
||||||
|
workdir: &Path,
|
||||||
|
rels: &[PathBuf],
|
||||||
|
) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||||
|
last_commits(&open_with_cache(workdir)?, rels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
|
||||||
|
///
|
||||||
|
/// Stops as soon as every pending path has its commit.
|
||||||
|
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||||
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
|
let Some(head) = repo.head_id().ok() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
// De-duplicate while preserving order.
|
||||||
|
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||||
|
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||||
|
|
||||||
|
for rel in rels {
|
||||||
|
if seen.insert(rel.as_path()) {
|
||||||
|
pending.push(rel.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let walk = repo
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
CommitTimeOrder::NewestFirst,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for info in walk.all()? {
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let info = info?;
|
||||||
|
let commit = info.object()?;
|
||||||
|
let tree = commit.tree()?;
|
||||||
|
let parent_tree = match info.parent_ids().next() {
|
||||||
|
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compare each unresolved path against this commit and its first parent.
|
||||||
|
// Resolved paths leave the pending set.
|
||||||
|
let mut ix = 0;
|
||||||
|
while ix < pending.len() {
|
||||||
|
let rel = &pending[ix];
|
||||||
|
let blob = tree.lookup_entry_by_path(rel)?;
|
||||||
|
let parent_blob = match &parent_tree {
|
||||||
|
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
||||||
|
{
|
||||||
|
found.push((rel.clone(), file_commit(&commit)?));
|
||||||
|
pending.swap_remove(ix);
|
||||||
|
} else {
|
||||||
|
ix += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
|
||||||
|
/// the tab badge shows the real count.
|
||||||
|
///
|
||||||
|
/// A huge history is never fully materialized in memory.
|
||||||
|
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||||
|
|
||||||
|
/// Commits reachable from `HEAD`, newest first, possibly capped.
|
||||||
|
pub struct CommitList {
|
||||||
|
/// Number of commits reachable from HEAD.
|
||||||
|
pub total: usize,
|
||||||
|
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||||
|
pub commits: Vec<FileCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||||
|
///
|
||||||
|
/// Returns an empty list for a repository without any commits yet.
|
||||||
|
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||||
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
|
let Some(head) = repo.head_id().ok() else {
|
||||||
|
return Ok(CommitList {
|
||||||
|
total: 0,
|
||||||
|
commits: Vec::new(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let walk = repo
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
CommitTimeOrder::NewestFirst,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
let mut total = 0;
|
||||||
|
|
||||||
|
for info in walk.all()? {
|
||||||
|
let info = info?;
|
||||||
|
total += 1;
|
||||||
|
if commits.len() < MAX_LISTED_COMMITS {
|
||||||
|
commits.push(file_commit_summary(&info.object()?)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CommitList { total, commits })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`all_commits`], but opens the repository at `workdir` first.
|
||||||
|
///
|
||||||
|
/// For non-bare clones the clone root is the worktree.
|
||||||
|
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||||
|
all_commits(&open_with_cache(workdir)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
|
||||||
|
pub fn worktree_commit_range_commits(
|
||||||
|
workdir: &Path,
|
||||||
|
base: &str,
|
||||||
|
tip: &str,
|
||||||
|
) -> Result<Vec<FileCommit>> {
|
||||||
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
|
let repo = open_with_cache(workdir)?;
|
||||||
|
let base_id = repo.rev_parse_single(base.as_bytes())?;
|
||||||
|
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||||
|
let walk = repo
|
||||||
|
.rev_walk([tip_id])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
CommitTimeOrder::NewestFirst,
|
||||||
|
))
|
||||||
|
.with_hidden([base_id]);
|
||||||
|
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
|
||||||
|
for info in walk.all()? {
|
||||||
|
let info = info?;
|
||||||
|
commits.push(file_commit_summary(&info.object()?)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(commits)
|
||||||
|
}
|
||||||
|
/// The commit HEAD points to, like `git log -1`.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
||||||
|
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||||
|
let Some(head) = repo.head_id().ok() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let commit = head.object()?.into_commit();
|
||||||
|
Ok(Some(file_commit(&commit)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
|
||||||
|
/// Like [`head_commit`] for an arbitrary commit.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` when the id cannot be resolved.
|
||||||
|
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
|
||||||
|
let repo = gix::open(workdir)?;
|
||||||
|
match repo.rev_parse_single(id.as_bytes()) {
|
||||||
|
Ok(commit_id) => {
|
||||||
|
let commit = commit_id.object()?.into_commit();
|
||||||
|
Ok(Some(file_commit(&commit)?))
|
||||||
|
}
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-3519
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||||
|
use diffy::{Hunk, Line};
|
||||||
|
|
||||||
|
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||||
|
use crate::history::FileCommit;
|
||||||
|
|
||||||
|
/// Apply a `git format-patch` patch or series with `git am`,
|
||||||
|
/// uses the git CLI because it handles the mbox format natively.
|
||||||
|
///
|
||||||
|
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
|
||||||
|
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||||
|
let mut child = Command::new("git")
|
||||||
|
.arg("am")
|
||||||
|
.current_dir(repo_path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("failed to spawn `git am`")?;
|
||||||
|
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.expect("stdin piped")
|
||||||
|
.write_all(patch.as_bytes())?;
|
||||||
|
|
||||||
|
let output = child.wait_with_output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
|
||||||
|
/// Fails when the range has no commits.
|
||||||
|
///
|
||||||
|
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
|
||||||
|
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo_path)
|
||||||
|
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.context("failed to spawn `git format-patch`")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"git format-patch failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||||
|
|
||||||
|
if patch.trim().is_empty() {
|
||||||
|
bail!("no commits between {base} and {tip}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a `git format-patch` series into its individual patches, mbox messages.
|
||||||
|
///
|
||||||
|
/// A single patch yields one element.
|
||||||
|
/// A malformed input yields one element covering it.
|
||||||
|
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||||
|
let mut starts = vec![0usize];
|
||||||
|
let mut search_from = 1;
|
||||||
|
|
||||||
|
while let Some(rel) = patch[search_from..].find("\nFrom ") {
|
||||||
|
let ix = search_from + rel + 1;
|
||||||
|
let hex = patch[ix + 5..]
|
||||||
|
.split(|c: char| !c.is_ascii_hexdigit())
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
if hex.len() == 40 {
|
||||||
|
starts.push(ix);
|
||||||
|
}
|
||||||
|
search_from = ix + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
starts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &start)| {
|
||||||
|
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
|
||||||
|
&patch[start..end]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `git format-patch` output, a single patch or a series.
|
||||||
|
///
|
||||||
|
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
|
||||||
|
/// `diff --git` headers, rename and copy detection, binary detection, and
|
||||||
|
/// C-style quoted or octal-escaped paths.
|
||||||
|
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||||
|
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||||
|
return Ok(CommitDiff { files: Vec::new() });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||||
|
files.push(file_diff(file?)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CommitDiff { files })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||||
|
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||||
|
let lines: Vec<&str> = patch.lines().collect();
|
||||||
|
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < lines.len() {
|
||||||
|
// A patch starts with its `From <id> <date>` envelope line.
|
||||||
|
let Some(rest) = lines[i].strip_prefix("From ") else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(id) = rest.split_whitespace().next() else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if id.len() != 40 {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut author = String::new();
|
||||||
|
let mut summary = String::new();
|
||||||
|
let mut time = 0i64;
|
||||||
|
|
||||||
|
// Envelope headers run up to the blank line before the commit message.
|
||||||
|
i += 1;
|
||||||
|
while i < lines.len() && !lines[i].is_empty() {
|
||||||
|
let header = lines[i];
|
||||||
|
if let Some(value) = header.strip_prefix("From: ") {
|
||||||
|
author = name_from_address(value);
|
||||||
|
} else if let Some(value) = header.strip_prefix("Subject: ") {
|
||||||
|
summary = strip_patch_prefix(value);
|
||||||
|
} else if let Some(value) = header.strip_prefix("Date: ") {
|
||||||
|
time = gix::date::parse(value.trim(), None)
|
||||||
|
.map(|t| t.seconds)
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
commits.push(FileCommit {
|
||||||
|
id: id.to_string(),
|
||||||
|
summary,
|
||||||
|
description: None,
|
||||||
|
author,
|
||||||
|
time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
commits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name part of a `From: Name <email>` header value.
|
||||||
|
fn name_from_address(from: &str) -> String {
|
||||||
|
match from.trim().find('<') {
|
||||||
|
Some(ix) => from[..ix].trim().to_string(),
|
||||||
|
None => from.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the patch prefix from a `Subject:` header.
|
||||||
|
///
|
||||||
|
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
||||||
|
fn strip_patch_prefix(subject: &str) -> String {
|
||||||
|
let trimmed = subject.trim();
|
||||||
|
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
let Some(end) = rest.find(']') else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||||
|
rest[end + 1..].trim().to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use gix::interrupt::IS_INTERRUPTED;
|
||||||
|
use gix::progress::Discard;
|
||||||
|
|
||||||
|
/// Clone into `path` from the first working URL in `clone_urls`.
|
||||||
|
///
|
||||||
|
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
||||||
|
pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
|
||||||
|
if path.exists() {
|
||||||
|
bail!("destination {} already exists", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
try_each_url(clone_urls, "clone", |url| {
|
||||||
|
let repo = clone(url, path)?;
|
||||||
|
// The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs.
|
||||||
|
fetch_all(&repo).ok();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
||||||
|
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
||||||
|
let options = gix::remote::ref_map::Options {
|
||||||
|
extra_refspecs: vec![
|
||||||
|
gix::refspec::parse(
|
||||||
|
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
|
||||||
|
gix::refspec::parse::Operation::Fetch,
|
||||||
|
)?
|
||||||
|
.to_owned(),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
repo.find_remote("origin")?
|
||||||
|
.connect(gix::remote::Direction::Fetch)?
|
||||||
|
.prepare_fetch(Discard, options)?
|
||||||
|
.receive(Discard, &IS_INTERRUPTED)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
|
||||||
|
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo_path)
|
||||||
|
.args(["push"])
|
||||||
|
.arg(url)
|
||||||
|
.arg(format!("{commit}:{reference}"))
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.context("failed to spawn `git push`")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"git push failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
||||||
|
///
|
||||||
|
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||||
|
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||||
|
fn transport_url(url: &str) -> String {
|
||||||
|
url.strip_prefix("grasp://")
|
||||||
|
.map(|rest| format!("https://{rest}"))
|
||||||
|
.unwrap_or_else(|| url.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `attempt` against each URL in `urls` until one succeeds.
|
||||||
|
///
|
||||||
|
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
||||||
|
/// or `no clone URLs provided` when the list is empty.
|
||||||
|
fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Result<()>,
|
||||||
|
{
|
||||||
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
match attempt(url.as_ref()) {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match last_err {
|
||||||
|
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
|
||||||
|
None => bail!("no clone URLs provided"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||||
|
let url = transport_url(url);
|
||||||
|
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||||
|
|
||||||
|
let mut prepare = gix::prepare_clone(url, path)?;
|
||||||
|
let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?;
|
||||||
|
let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?;
|
||||||
|
|
||||||
|
Ok(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
|
||||||
|
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
|
push_refspecs(
|
||||||
|
repo_path,
|
||||||
|
base_url,
|
||||||
|
owner,
|
||||||
|
repo_id,
|
||||||
|
&["refs/heads/main:refs/heads/main"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
||||||
|
///
|
||||||
|
/// This mirrors an initialized repository's whole history.
|
||||||
|
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
|
push_refspecs(
|
||||||
|
repo_path,
|
||||||
|
base_url,
|
||||||
|
owner,
|
||||||
|
repo_id,
|
||||||
|
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
|
||||||
|
fn push_refspecs(
|
||||||
|
repo_path: &Path,
|
||||||
|
base_url: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo_id: &str,
|
||||||
|
refspecs: &[&str],
|
||||||
|
) -> Result<()> {
|
||||||
|
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||||
|
|
||||||
|
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||||
|
args.push("push");
|
||||||
|
args.push(&url);
|
||||||
|
args.extend_from_slice(refspecs);
|
||||||
|
|
||||||
|
let output = git_output(repo_path, &args, "git push")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"git push to {base_url} failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` advertises every ref in `expected` at the given commit.
|
||||||
|
///
|
||||||
|
/// Extra advertised refs are ignored: the question is whether the data this
|
||||||
|
/// push wanted to land is already there, not whether the remote is an exact mirror.
|
||||||
|
/// This is the convergence probe for a push that lost the compare-and-swap race
|
||||||
|
/// to the grasp server's own background ref alignment.
|
||||||
|
pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||||
|
if expected.is_empty() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = gix::open(repo_path)?;
|
||||||
|
let url = transport_url(url);
|
||||||
|
|
||||||
|
// A URL-created remote has no configured fetch refspecs, and `ref_map` only
|
||||||
|
// keeps refs that match one. Match each expected ref by its exact name,
|
||||||
|
// like `git ls-remote <url> <name>` would; ref maps never write to the repository.
|
||||||
|
let refspecs = expected
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| {
|
||||||
|
gix::refspec::parse(
|
||||||
|
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
||||||
|
gix::refspec::parse::Operation::Fetch,
|
||||||
|
)
|
||||||
|
.map(|spec| spec.to_owned())
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.context("invalid refspec")?;
|
||||||
|
|
||||||
|
let options = gix::remote::ref_map::Options {
|
||||||
|
extra_refspecs: refspecs,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (refs, _) = repo
|
||||||
|
.remote_at(url.as_str())
|
||||||
|
.with_context(|| format!("cannot use remote {url}"))?
|
||||||
|
.connect(gix::remote::Direction::Fetch)
|
||||||
|
.with_context(|| format!("cannot connect to {url}"))?
|
||||||
|
.ref_map(Discard, options)
|
||||||
|
.with_context(|| format!("listing refs of {url} failed"))?;
|
||||||
|
|
||||||
|
// Peeled tag entries carry the tag object in their direct oid, so mapping
|
||||||
|
// each advertised ref to its direct oid matches `git ls-remote` while
|
||||||
|
// skipping the duplicated `^{}` lines.
|
||||||
|
let advertised: HashMap<String, String> = refs
|
||||||
|
.remote_refs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reference| {
|
||||||
|
let (name, object, _peeled) = reference.unpack();
|
||||||
|
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(expected
|
||||||
|
.iter()
|
||||||
|
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `origin` pointing at `url` when the repository has no remote yet.
|
||||||
|
///
|
||||||
|
/// No-op if `origin` already exists.
|
||||||
|
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||||
|
let repo = gix::open(repo_path)?;
|
||||||
|
if repo.find_remote("origin").is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// `git remote add` also configures the default fetch refspec.
|
||||||
|
edit_local_config(&repo, |config| {
|
||||||
|
config.set_raw_value("remote.origin.url", url)?;
|
||||||
|
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point `origin` at `url`, replacing an existing remote,
|
||||||
|
/// used after a clone whose `origin` points at the cloned-from path.
|
||||||
|
///
|
||||||
|
/// A working copy cloned from a local mirror is re-targeted at the grasp server.
|
||||||
|
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||||
|
let repo = gix::open(repo_path)?;
|
||||||
|
let had_origin = repo.find_remote("origin").is_ok();
|
||||||
|
|
||||||
|
edit_local_config(&repo, |config| {
|
||||||
|
// Replaces the existing url, like `git remote set-url origin <url>`.
|
||||||
|
// A pre-existing fetch refspec is left untouched.
|
||||||
|
config.set_raw_value("remote.origin.url", url)?;
|
||||||
|
|
||||||
|
if !had_origin {
|
||||||
|
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `edit` to the repository-local configuration and persist it.
|
||||||
|
///
|
||||||
|
/// The config file is locked while it is read, edited and written back,
|
||||||
|
/// like git would when running `git config` or `git remote`.
|
||||||
|
fn edit_local_config(
|
||||||
|
repo: &gix::Repository,
|
||||||
|
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let config_path = repo.common_dir().join("config");
|
||||||
|
|
||||||
|
let mut lock = gix::lock::File::acquire_to_update_resource(
|
||||||
|
&config_path,
|
||||||
|
gix::lock::acquire::Fail::Immediately,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("failed to lock repository config")?;
|
||||||
|
|
||||||
|
let mut config =
|
||||||
|
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) {
|
||||||
|
Ok(config) => config,
|
||||||
|
// A repository without a config file yet starts from scratch.
|
||||||
|
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
|
||||||
|
if source.kind() == std::io::ErrorKind::NotFound =>
|
||||||
|
{
|
||||||
|
gix::config::File::default()
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error).context("failed to read repository config"),
|
||||||
|
};
|
||||||
|
|
||||||
|
edit(&mut config)?;
|
||||||
|
|
||||||
|
config
|
||||||
|
.write_to(&mut lock)
|
||||||
|
.context("failed to write repository config")?;
|
||||||
|
|
||||||
|
lock.commit().context("failed to save repository config")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch `refspec` into `repo_path` from the first working URL in `urls`.
|
||||||
|
/// When no URL works, the last error is returned.
|
||||||
|
///
|
||||||
|
/// Never touches the checked-out refs or the worktree.
|
||||||
|
pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
|
||||||
|
let repo = gix::open(repo_path)?;
|
||||||
|
let refspec = gix::refspec::parse(
|
||||||
|
gix::bstr::BStr::new(refspec),
|
||||||
|
gix::refspec::parse::Operation::Fetch,
|
||||||
|
)
|
||||||
|
.context("invalid fetch refspec")?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
try_each_url(urls, "fetch", |url| {
|
||||||
|
let url = transport_url(url);
|
||||||
|
let options = gix::remote::ref_map::Options {
|
||||||
|
extra_refspecs: vec![refspec.clone()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
repo.remote_at(url.as_str())
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?
|
||||||
|
.connect(gix::remote::Direction::Fetch)
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?
|
||||||
|
.prepare_fetch(Discard, options)
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?
|
||||||
|
.receive(Discard, &IS_INTERRUPTED)
|
||||||
|
.with_context(|| format!("fetch from {url} failed"))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL of the `origin` remote of the repository at `workdir`.
|
||||||
|
///
|
||||||
|
/// `None` when it has no `origin` yet.
|
||||||
|
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
||||||
|
let Ok(repo) = gix::open(workdir) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(remote) = repo.find_remote("origin") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(remote
|
||||||
|
.url(gix::remote::Direction::Fetch)
|
||||||
|
.map(|url| url.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
||||||
|
///
|
||||||
|
/// `what` names the command in the spawn error.
|
||||||
|
pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(dir)
|
||||||
|
.args(args)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("failed to spawn `{what}`"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
use crate::history::open_with_cache;
|
||||||
|
use crate::worktree::{force_checkout, worktree_dirty};
|
||||||
|
|
||||||
|
/// The merge base of two revisions in the repository at `repo_path`,
|
||||||
|
/// revisions may be branch names, remote-tracking refs or commit ids.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` when the revisions share no common ancestor.
|
||||||
|
///
|
||||||
|
/// Unresolvable revisions are errors.
|
||||||
|
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
||||||
|
let repo = open_with_cache(repo_path)?;
|
||||||
|
let a = repo.rev_parse_single(a.as_bytes())?;
|
||||||
|
let b = repo.rev_parse_single(b.as_bytes())?;
|
||||||
|
match repo.merge_base(a, b) {
|
||||||
|
Ok(id) => Ok(Some(id.to_string())),
|
||||||
|
// No common ancestor, a valid outcome for a proposal.
|
||||||
|
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The commit HEAD points to in the repository at `repo_path`.
|
||||||
|
///
|
||||||
|
/// `None` when the repository has no commits yet, an unborn HEAD.
|
||||||
|
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
||||||
|
let Ok(repo) = gix::open(repo_path) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match repo.head_id() {
|
||||||
|
Ok(id) => Ok(Some(id.to_string())),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first.
|
||||||
|
/// This is the order `git am` creates them.
|
||||||
|
///
|
||||||
|
/// `HEAD` alone when `base` is `None`.
|
||||||
|
pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>> {
|
||||||
|
let repo = match gix::open(repo_path) {
|
||||||
|
Ok(repo) => repo,
|
||||||
|
Err(_) if base.is_none() => return Ok(Vec::new()),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let head = match repo.head_id() {
|
||||||
|
Ok(head) => head,
|
||||||
|
Err(_) if base.is_none() => return Ok(Vec::new()),
|
||||||
|
Err(e) => return Err(e).context("repository has no commits"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(base) = base else {
|
||||||
|
// `HEAD` alone when no base is given.
|
||||||
|
return Ok(vec![head.to_string()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
let base = repo.rev_parse_single(base.as_bytes())?;
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
|
||||||
|
for info in repo
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||||
|
))
|
||||||
|
.with_hidden([base])
|
||||||
|
.all()?
|
||||||
|
{
|
||||||
|
commits.push(info?.id().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest first, like `git rev-list --reverse`, the order `git am` creates them.
|
||||||
|
commits.reverse();
|
||||||
|
|
||||||
|
Ok(commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity written to reflogs and commits created by this crate itself.
|
||||||
|
///
|
||||||
|
/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works
|
||||||
|
/// without a global git identity, and `gix` runs no hooks and never signs.
|
||||||
|
pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) {
|
||||||
|
let seconds = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs() as i64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let signature = gix::actor::Signature {
|
||||||
|
name: gix::bstr::BString::from("Signed"),
|
||||||
|
email: gix::bstr::BString::from("signed@localhost"),
|
||||||
|
time: gix::date::Time { seconds, offset: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
(signature, gix::date::parse::TimeBuf::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a repository at `path` with an initial `main` branch.
|
||||||
|
/// Write a `README.md` from `name` and `description`, then create the initial commit.
|
||||||
|
///
|
||||||
|
/// Returns the initial commit id.
|
||||||
|
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||||
|
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||||
|
|
||||||
|
std::fs::create_dir_all(path)
|
||||||
|
.with_context(|| format!("failed to create {}", path.display()))?;
|
||||||
|
|
||||||
|
let repo = gix::init(path)?;
|
||||||
|
|
||||||
|
let (signature, mut time_buf) = repository_signature();
|
||||||
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
|
// The initial branch is `main`, regardless of `init.defaultBranch` in
|
||||||
|
// the user's git configuration: point the unborn HEAD there.
|
||||||
|
let head = gix::refs::FullName::try_from("HEAD")
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
|
|
||||||
|
repo.edit_references_as(
|
||||||
|
[RefEdit {
|
||||||
|
change: Change::Update {
|
||||||
|
log: LogChange {
|
||||||
|
mode: RefLog::AndReference,
|
||||||
|
force_create_reflog: false,
|
||||||
|
message: "checkout: moving to main".into(),
|
||||||
|
},
|
||||||
|
expected: PreviousValue::Any,
|
||||||
|
new: gix::refs::Target::Symbolic(
|
||||||
|
gix::refs::FullName::try_from("refs/heads/main")
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
name: head,
|
||||||
|
deref: false,
|
||||||
|
}],
|
||||||
|
Some(signature),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let readme = if description.trim().is_empty() {
|
||||||
|
format!("# {name}\n")
|
||||||
|
} else {
|
||||||
|
format!("# {name}\n\n{description}\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?;
|
||||||
|
|
||||||
|
let blob = repo.write_object(gix::objs::Blob {
|
||||||
|
data: readme.into_bytes(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tree = repo.write_object(gix::objs::Tree {
|
||||||
|
entries: vec![gix::objs::tree::Entry {
|
||||||
|
mode: gix::objs::tree::EntryKind::Blob.into(),
|
||||||
|
filename: gix::bstr::BString::from("README.md"),
|
||||||
|
oid: blob.into(),
|
||||||
|
}],
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let commit = repo.commit_as(
|
||||||
|
signature,
|
||||||
|
signature,
|
||||||
|
"HEAD",
|
||||||
|
"Initial commit",
|
||||||
|
tree,
|
||||||
|
Vec::<gix::ObjectId>::new(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Populate the index so the fresh repository is clean,
|
||||||
|
// as `git add` and`git commit` would leave it.
|
||||||
|
let mut index = repo.index_from_tree(&tree)?;
|
||||||
|
index.write(gix::index::write::Options::default())?;
|
||||||
|
|
||||||
|
let commit = commit.to_string();
|
||||||
|
if commit.len() != 40 {
|
||||||
|
bail!("unexpected initial commit id: {commit}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The earliest unique commit of the repository at `repo_path`.
|
||||||
|
/// Used as the NIP-34 announcement's `euc` marker.
|
||||||
|
///
|
||||||
|
/// `None` for a repository without commits.
|
||||||
|
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||||
|
let Ok(repo) = gix::open(repo_path) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(head) = repo.head_id() else {
|
||||||
|
// An unborn HEAD with no commits yet has no root commit.
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
for info in repo
|
||||||
|
.rev_walk([head])
|
||||||
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
|
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||||
|
))
|
||||||
|
.all()?
|
||||||
|
{
|
||||||
|
let info = info?;
|
||||||
|
if info.parent_ids().next().is_none() {
|
||||||
|
let id = info.id().to_string();
|
||||||
|
return Ok((id.len() == 40).then_some(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`.
|
||||||
|
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||||
|
///
|
||||||
|
/// Returns an empty list when nothing matches.
|
||||||
|
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||||
|
let pattern = prefix.trim_end_matches('/');
|
||||||
|
let repo = gix::open(repo_path)?;
|
||||||
|
let mut names = Vec::new();
|
||||||
|
|
||||||
|
for reference in repo.references()?.all()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned();
|
||||||
|
|
||||||
|
// Match the pattern itself and everything beneath it, like `git for-each-ref`.
|
||||||
|
let under_pattern = name
|
||||||
|
.strip_prefix(pattern)
|
||||||
|
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||||
|
|
||||||
|
if under_pattern {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort lexicographically, like `git for-each-ref`.
|
||||||
|
names.sort();
|
||||||
|
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every ref under `prefix` of the repository at `repo_path`.
|
||||||
|
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||||
|
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||||
|
use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
|
||||||
|
|
||||||
|
let refs = refs_with_prefix(repo_path, prefix)?;
|
||||||
|
if refs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = gix::open(repo_path)?;
|
||||||
|
let edits: Vec<RefEdit> = refs
|
||||||
|
.iter()
|
||||||
|
.map(|name| {
|
||||||
|
let full = gix::refs::FullName::try_from(name.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?;
|
||||||
|
Ok(RefEdit {
|
||||||
|
change: Change::Delete {
|
||||||
|
expected: PreviousValue::Any,
|
||||||
|
log: RefLog::AndReference,
|
||||||
|
},
|
||||||
|
name: full,
|
||||||
|
deref: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
// Delete all refs with the given prefix.
|
||||||
|
repo.edit_references(edits)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short name of the branch HEAD points to at `workdir`,
|
||||||
|
/// `None` when detached or unreadable, like `git branch --show-current`.
|
||||||
|
pub fn worktree_current_branch(workdir: &Path) -> Option<String> {
|
||||||
|
let repo = gix::open(workdir).ok()?;
|
||||||
|
let head = repo.head().ok()?;
|
||||||
|
let name = head.referent_name()?;
|
||||||
|
Some(String::from_utf8_lossy(name.shorten()).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the reference `name` exists in the repository at `workdir`.
|
||||||
|
pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool {
|
||||||
|
let Ok(repo) = gix::open(workdir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
repo.find_reference(name).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast-forward local branches that trail their remote-tracking counterpart.
|
||||||
|
///
|
||||||
|
/// Returns whether any branch moved.
|
||||||
|
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||||
|
let repo = gix::open(workdir)?;
|
||||||
|
let current = worktree_current_branch(workdir);
|
||||||
|
let heads = refs_with_prefix(workdir, "refs/heads")?;
|
||||||
|
|
||||||
|
let (signature, mut time_buf) = repository_signature();
|
||||||
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
|
let mut moved = false;
|
||||||
|
|
||||||
|
for head in heads {
|
||||||
|
let Some(branch) = head.strip_prefix("refs/heads/") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let remote = format!("refs/remotes/origin/{branch}");
|
||||||
|
// No remote-tracking counterpart means the remote lacks this branch.
|
||||||
|
let Ok(mut remote_reference) = repo.find_reference(&remote) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut local_reference) = repo.find_reference(&head) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(remote_oid) = remote_reference.peel_to_id() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(local_oid) = local_reference.peel_to_id() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let remote_oid = remote_oid.detach();
|
||||||
|
let local_oid = local_oid.detach();
|
||||||
|
|
||||||
|
if local_oid == remote_oid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fast-forward.
|
||||||
|
// Local-only commits or diverged history must never be rewritten by a refresh.
|
||||||
|
let Ok(base) = repo.merge_base(local_oid, remote_oid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if base != local_oid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full = gix::refs::FullName::try_from(head.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
|
|
||||||
|
let edit = |new: gix::refs::Target| {
|
||||||
|
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||||
|
RefEdit {
|
||||||
|
change: Change::Update {
|
||||||
|
log: LogChange {
|
||||||
|
mode: RefLog::AndReference,
|
||||||
|
force_create_reflog: false,
|
||||||
|
message: format!("merge {remote}: Fast-forward").into(),
|
||||||
|
},
|
||||||
|
expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object(
|
||||||
|
local_oid,
|
||||||
|
)),
|
||||||
|
new,
|
||||||
|
},
|
||||||
|
name: full.clone(),
|
||||||
|
deref: false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if current.as_deref() == Some(branch) {
|
||||||
|
// Merge so the checked-out worktree follows the branch.
|
||||||
|
// Only proceed on a clean worktree, like `git merge --ff-only`.
|
||||||
|
if worktree_dirty(workdir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id;
|
||||||
|
|
||||||
|
// Check out the remote tree, discarding local changes.
|
||||||
|
force_checkout(&repo, &tree)?;
|
||||||
|
|
||||||
|
// Update the branch reference to point to the remote tree.
|
||||||
|
repo.edit_references_as(
|
||||||
|
[edit(gix::refs::Target::Object(remote_oid))],
|
||||||
|
Some(signature),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
moved = true;
|
||||||
|
} else {
|
||||||
|
// Update the branch reference to point to the remote tree.
|
||||||
|
repo.edit_references_as(
|
||||||
|
[edit(gix::refs::Target::Object(remote_oid))],
|
||||||
|
Some(signature),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically.
|
||||||
|
pub fn repo_branches(repo: &gix::Repository) -> Result<Vec<String>> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for reference in repo.references()?.local_branches()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically.
|
||||||
|
pub fn repo_tags(repo: &gix::Repository) -> Result<Vec<String>> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for reference in repo.references()?.tags()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short names of local branches, `refs/heads/*`, sorted alphabetically.
|
||||||
|
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||||
|
repo_branches(&gix::open(workdir)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short name of the branch HEAD points to, or `None` when detached.
|
||||||
|
///
|
||||||
|
/// Detached after checking out a tag or a commit directly.
|
||||||
|
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
||||||
|
let head = repo.head()?;
|
||||||
|
let Some(name) = head.referent_name() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Branch, tag and HEAD refs of a repository.
|
||||||
|
///
|
||||||
|
/// Ready for a NIP-34 kind-30618 repository state announcement.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RepoRefState {
|
||||||
|
/// `(full refname, commit id)` pairs for heads and tags, sorted.
|
||||||
|
pub refs: Vec<(String, String)>,
|
||||||
|
/// Short branch name HEAD points to, or `None` when detached.
|
||||||
|
pub head: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the refs of `repo`.
|
||||||
|
///
|
||||||
|
/// Local branches and tags become `(refname, commit-id)` pairs.
|
||||||
|
/// Also reports the branch HEAD points to.
|
||||||
|
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
|
||||||
|
for reference in repo.references()?.local_branches()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
refs.push((
|
||||||
|
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||||
|
reference.id().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for reference in repo.references()?.tags()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
refs.push((
|
||||||
|
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||||
|
reference.id().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
refs.sort();
|
||||||
|
|
||||||
|
let head = match repo.head() {
|
||||||
|
Ok(head) => head
|
||||||
|
.referent_name()
|
||||||
|
.filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
|
||||||
|
.map(|name| String::from_utf8_lossy(name.shorten()).into_owned()),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RepoRefState { refs, head })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`repo_ref_state`] for the repository at `workdir`.
|
||||||
|
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
|
||||||
|
repo_ref_state(&gix::open(workdir)?)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use ignore::WalkBuilder;
|
||||||
|
|
||||||
|
/// Maximum directory nesting depth when scanning for local repositories.
|
||||||
|
///
|
||||||
|
/// Pathological trees can't stall the scan.
|
||||||
|
const SCAN_MAX_DEPTH: usize = 12;
|
||||||
|
|
||||||
|
/// Walk `root` recursively and collect the paths of git repositories below it,
|
||||||
|
/// honouring `.gitignore` (and `.ignore`) files.
|
||||||
|
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let walker = WalkBuilder::new(root)
|
||||||
|
.max_depth(Some(SCAN_MAX_DEPTH))
|
||||||
|
// Honour `.gitignore` even when the scan root is not itself a repository.
|
||||||
|
.require_git(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut repos: Vec<PathBuf> = walker
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir()))
|
||||||
|
.map(ignore::DirEntry::into_path)
|
||||||
|
.filter(|dir| dir.join(".git").exists())
|
||||||
|
.filter_map(|dir| dir.canonicalize().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
repos.sort();
|
||||||
|
repos.dedup();
|
||||||
|
|
||||||
|
// A repository nested inside another, like a submodule worktree, is not reported.
|
||||||
|
let mut roots: Vec<PathBuf> = Vec::with_capacity(repos.len());
|
||||||
|
for repo in repos {
|
||||||
|
if !roots.iter().any(|kept| repo.starts_with(kept)) {
|
||||||
|
roots.push(repo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use gix::progress::Discard;
|
||||||
|
|
||||||
|
use crate::history::{FileCommit, head_commit};
|
||||||
|
use crate::repo::{current_branch, repository_signature};
|
||||||
|
|
||||||
|
/// Whether the worktree of `workdir` has uncommitted changes.
|
||||||
|
///
|
||||||
|
/// Best-effort: any read failure is reported as clean.
|
||||||
|
pub fn worktree_dirty(workdir: &Path) -> bool {
|
||||||
|
let Ok(repo) = gix::open(workdir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Changes to tracked files, staged or not; untracked files are excluded.
|
||||||
|
match repo.is_dirty() {
|
||||||
|
Ok(true) => return true,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk,
|
||||||
|
// tracked files only appear there when modified.
|
||||||
|
let Ok(platform) = repo.status(Discard) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for change in changes.by_ref() {
|
||||||
|
match change {
|
||||||
|
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits in `base..branch` of the checkout at `workdir`.
|
||||||
|
///
|
||||||
|
/// Best-effort: 0 when the range cannot be computed.
|
||||||
|
pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
|
||||||
|
let Ok(repo) = gix::open(workdir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch))
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `rev` to a commit id, accepting full refs,
|
||||||
|
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
|
||||||
|
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
|
||||||
|
if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch names arrive bare, like git resolving `main`.
|
||||||
|
if rev.contains('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes())
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative paths of all entries in the worktree, files and directories.
|
||||||
|
///
|
||||||
|
/// The `.git` directory is skipped.
|
||||||
|
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
||||||
|
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||||
|
|
||||||
|
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
||||||
|
collect_entries(workdir, workdir, &mut entries)?;
|
||||||
|
|
||||||
|
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
||||||
|
b_is_dir
|
||||||
|
.cmp(a_is_dir)
|
||||||
|
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
||||||
|
});
|
||||||
|
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file from the worktree.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` if the path is missing or not a regular file.
|
||||||
|
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
|
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||||
|
let path = workdir.join(rel);
|
||||||
|
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => Ok(Some(bytes)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
||||||
|
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the README file in the repository root.
|
||||||
|
///
|
||||||
|
/// Falls back to any other file whose name starts with `readme`.
|
||||||
|
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||||
|
let Some(workdir) = repo.workdir() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(workdir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if name.to_ascii_lowercase().starts_with("readme") {
|
||||||
|
candidates.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort_by_key(|path| {
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||||
|
match ext.as_deref() {
|
||||||
|
Some("md") => 0,
|
||||||
|
Some("markdown") => 1,
|
||||||
|
Some("mdown") => 2,
|
||||||
|
Some("mkdn") => 3,
|
||||||
|
Some(_) => 5,
|
||||||
|
None => 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(candidates
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||||
|
pub struct WorktreeSnapshot {
|
||||||
|
/// Relative paths of all worktree entries, directories first.
|
||||||
|
pub entries: Vec<PathBuf>,
|
||||||
|
/// README path relative to the worktree, if any.
|
||||||
|
pub readme_path: Option<PathBuf>,
|
||||||
|
/// Contents of the README, if any.
|
||||||
|
pub readme: Option<Vec<u8>>,
|
||||||
|
/// Branch HEAD points to, `None` when detached, for example on a tag.
|
||||||
|
pub current_branch: Option<String>,
|
||||||
|
/// Commit HEAD points to, if any, see [`head_commit`].
|
||||||
|
pub head_commit: Option<FileCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the worktree after a branch or tag switch.
|
||||||
|
///
|
||||||
|
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||||
|
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||||
|
let repo = gix::open(workdir)?;
|
||||||
|
let readme_path = find_readme(&repo)?;
|
||||||
|
let readme = match &readme_path {
|
||||||
|
Some(path) => worktree_read(&repo, path)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(WorktreeSnapshot {
|
||||||
|
entries: worktree_entries(&repo)?,
|
||||||
|
readme_path,
|
||||||
|
readme,
|
||||||
|
current_branch: current_branch(&repo)?,
|
||||||
|
head_commit: head_commit(&repo)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check out `tree` into the worktree of `repo`
|
||||||
|
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
|
||||||
|
let workdir = repo
|
||||||
|
.workdir()
|
||||||
|
.context("repository has no worktree")?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
let mut index = repo.index_from_tree(tree)?;
|
||||||
|
|
||||||
|
// Files the previous index tracked but `tree` no longer contains are removed,
|
||||||
|
// like git deleting files that vanish between branches.
|
||||||
|
if let Ok(previous) = repo.index_or_empty() {
|
||||||
|
let keep: HashSet<PathBuf> = index
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()))
|
||||||
|
.collect();
|
||||||
|
for entry in previous.entries() {
|
||||||
|
let rel = entry.path(&previous);
|
||||||
|
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
|
||||||
|
|
||||||
|
if keep.contains(&rel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = workdir.join(&rel);
|
||||||
|
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error)
|
||||||
|
.with_context(|| format!("failed to remove {}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut options =
|
||||||
|
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
||||||
|
options.overwrite_existing = true;
|
||||||
|
|
||||||
|
let objects = repo.objects.clone().into_arc()?;
|
||||||
|
let files = gix::progress::Discard;
|
||||||
|
let bytes = gix::progress::Discard;
|
||||||
|
|
||||||
|
// Check out the index into the worktree.
|
||||||
|
gix_worktree_state::checkout(
|
||||||
|
&mut index,
|
||||||
|
workdir,
|
||||||
|
objects,
|
||||||
|
&files,
|
||||||
|
&bytes,
|
||||||
|
&gix::interrupt::IS_INTERRUPTED,
|
||||||
|
options,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Write the index to disk.
|
||||||
|
index.write(gix::index::write::Options::default())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point `HEAD` at `target` and record the switch in the reflog.
|
||||||
|
fn move_head(
|
||||||
|
repo: &gix::Repository,
|
||||||
|
signature: gix::actor::SignatureRef<'_>,
|
||||||
|
target: gix::refs::Target,
|
||||||
|
message: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||||
|
|
||||||
|
let head = gix::refs::FullName::try_from("HEAD")
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
|
|
||||||
|
// Update the reference, creating a reflog entry.
|
||||||
|
repo.edit_references_as(
|
||||||
|
[RefEdit {
|
||||||
|
change: Change::Update {
|
||||||
|
log: LogChange {
|
||||||
|
mode: RefLog::AndReference,
|
||||||
|
force_create_reflog: false,
|
||||||
|
message: message.into(),
|
||||||
|
},
|
||||||
|
expected: PreviousValue::Any,
|
||||||
|
new: target,
|
||||||
|
},
|
||||||
|
name: head,
|
||||||
|
deref: false,
|
||||||
|
}],
|
||||||
|
Some(signature),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check out the local branch `name`, HEAD stays attached to it.
|
||||||
|
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
||||||
|
let repo = gix::open(workdir)?;
|
||||||
|
let full = format!("refs/heads/{name}");
|
||||||
|
|
||||||
|
let branch = gix::refs::FullName::try_from(full.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||||
|
|
||||||
|
let mut reference = repo.find_reference(&full)?;
|
||||||
|
let tree = reference.peel_to_tree()?.id;
|
||||||
|
|
||||||
|
let (signature, mut time_buf) = repository_signature();
|
||||||
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
|
// Move HEAD to the branch, creating a reflog entry.
|
||||||
|
move_head(
|
||||||
|
&repo,
|
||||||
|
signature,
|
||||||
|
gix::refs::Target::Symbolic(branch),
|
||||||
|
&format!("checkout: moving to {name}"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Check out the branch's tree, replacing index + worktree.
|
||||||
|
force_checkout(&repo, &tree)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check out the tag `name`, HEAD becomes detached at the tagged commit.
|
||||||
|
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
||||||
|
let repo = gix::open(workdir)?;
|
||||||
|
let full = format!("refs/tags/{name}");
|
||||||
|
|
||||||
|
let mut reference = repo.find_reference(&full)?;
|
||||||
|
|
||||||
|
let commit = reference.peel_to_id()?;
|
||||||
|
let tree = reference.peel_to_tree()?.id;
|
||||||
|
|
||||||
|
let (signature, mut time_buf) = repository_signature();
|
||||||
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
|
|
||||||
|
// Move HEAD to the tag, creating a reflog entry.
|
||||||
|
move_head(
|
||||||
|
&repo,
|
||||||
|
signature,
|
||||||
|
gix::refs::Target::Object(commit.detach()),
|
||||||
|
&format!("checkout: moving to {name}"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Check out the tag's tree, replacing index + worktree.
|
||||||
|
force_checkout(&repo, &tree)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_name() == ".git" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_dir = entry.file_type()?.is_dir();
|
||||||
|
let path = entry.path();
|
||||||
|
let rel = path.strip_prefix(root)?.to_path_buf();
|
||||||
|
out.push((rel, is_dir));
|
||||||
|
|
||||||
|
if is_dir {
|
||||||
|
collect_entries(root, &path, out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+896
-483
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,32 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use std::time::{Duration, 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);
|
||||||
|
|
||||||
/// How often the statuses of open repository panels are refreshed.
|
/// How often the statuses are recomputed against the local refs.
|
||||||
|
///
|
||||||
|
/// A commit lands in a checkout long before the remote reconciliation cadence,
|
||||||
|
/// so this fast pass surfaces ready-to-push and ready-to-contribute checkouts
|
||||||
|
/// within a second or two. It reads the tracking refs only, no network.
|
||||||
|
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How often a full pass refreshes the remotes while any repository panel is open.
|
||||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// Background poll interval for the `ready to push` badges of the user's own repositories.
|
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
||||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// Maximum checkouts considered per repository when computing statuses.
|
/// Maximum checkouts considered per repository when computing statuses.
|
||||||
@@ -92,7 +97,13 @@ pub struct CheckoutsStore {
|
|||||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||||
/// Refresh coalescing, see [`RefreshGate`].
|
/// Refresh coalescing, see [`RefreshGate`].
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
/// A local status pass timer is pending.
|
||||||
|
local_pending: bool,
|
||||||
|
/// When the last full pass (with a remote refresh) completed.
|
||||||
|
///
|
||||||
|
/// The local pass runs a full pass again once this is older than the
|
||||||
|
/// reconciliation cadence, so remote moves still land.
|
||||||
|
last_full_sync: Option<Instant>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,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(),
|
||||||
@@ -150,23 +170,10 @@ impl CheckoutsStore {
|
|||||||
push_statuses: HashMap::new(),
|
push_statuses: HashMap::new(),
|
||||||
requested_head: HashMap::new(),
|
requested_head: HashMap::new(),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
tasks: Vec::new(),
|
local_pending: false,
|
||||||
|
last_full_sync: None,
|
||||||
_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.
|
||||||
@@ -287,15 +294,20 @@ 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 resolve and apply cycle, the debounced entry point.
|
/// One full resolve and apply cycle, the debounced entry point.
|
||||||
|
///
|
||||||
|
/// Re-resolves the associations from the settings, the scan and the
|
||||||
|
/// announcements, then recomputes the requested statuses against freshly
|
||||||
|
/// fetched remotes. Full passes run on every input change and on the
|
||||||
|
/// remote reconciliation cadence ([`Self::local_tick`]); they also restart
|
||||||
|
/// the fast local pass.
|
||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
|
|
||||||
@@ -361,46 +373,22 @@ impl CheckoutsStore {
|
|||||||
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
let (statuses, push_statuses) =
|
||||||
for (addr, announced_head) in &requested {
|
compute_statuses(&associations, &requested, &push_requested, true);
|
||||||
let Some(paths) = associations.get(addr) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let list: Vec<CheckoutStatus> = paths
|
|
||||||
.iter()
|
|
||||||
.take(MAX_STATUS_CHECKOUTS)
|
|
||||||
.filter_map(|path| checkout_status(path, announced_head.as_deref()))
|
|
||||||
.collect();
|
|
||||||
if !list.is_empty() {
|
|
||||||
statuses.insert(addr.clone(), list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
|
||||||
for addr in &push_requested {
|
|
||||||
let Some(paths) = associations.get(addr) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let list: Vec<CheckoutStatus> = paths
|
|
||||||
.iter()
|
|
||||||
.take(MAX_STATUS_CHECKOUTS)
|
|
||||||
.filter_map(|path| checkout_push_status(path))
|
|
||||||
.collect();
|
|
||||||
if !list.is_empty() {
|
|
||||||
push_statuses.insert(addr.clone(), list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(_) => {
|
||||||
// Git reads are best-effort, keep the last results.
|
// Git reads are best-effort, keep the last results.
|
||||||
return this.update(cx, |this, _cx| {
|
return this.update(cx, |this, cx| {
|
||||||
this.refresh.abort();
|
this.refresh.abort();
|
||||||
|
if poll {
|
||||||
|
this.schedule_local_pass(cx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -414,13 +402,13 @@ impl CheckoutsStore {
|
|||||||
this.statuses = statuses;
|
this.statuses = statuses;
|
||||||
this.push_statuses = push_statuses;
|
this.push_statuses = push_statuses;
|
||||||
|
|
||||||
// Poll cycles and identity re-requests recompute the same maps
|
// Notify only when something actually changed, so observers
|
||||||
// over and over. Notify only when something actually changed,
|
// skip the no-op heartbeats.
|
||||||
// so observers skip the no-op heartbeats.
|
|
||||||
if associations_changed || statuses_changed || push_statuses_changed {
|
if associations_changed || statuses_changed || push_statuses_changed {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.last_full_sync = Some(Instant::now());
|
||||||
this.refresh.finish()
|
this.refresh.finish()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -428,36 +416,133 @@ impl CheckoutsStore {
|
|||||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the statuses current while any repository panel is open.
|
// Restart the fast local pass so the freshly resolved
|
||||||
|
// associations drive it. The pass itself decides when the next
|
||||||
|
// full pass runs.
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
if poll && this.refresh.idle() {
|
if poll {
|
||||||
this.refresh.poll();
|
this.schedule_local_pass(cx);
|
||||||
|
|
||||||
// Open panels get the fast cadence.
|
|
||||||
// Each cycle fetches every watched checkout's remote.
|
|
||||||
let delay = if this.status_requested.is_empty() {
|
|
||||||
PUSH_POLL
|
|
||||||
} else {
|
|
||||||
STATUS_POLL
|
|
||||||
};
|
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(delay).await;
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
// A request that arrived while the poll was pending
|
|
||||||
// superseded it with its own debounce; skip the stale poll.
|
|
||||||
if this.refresh.take_poll() {
|
|
||||||
this.run_refresh(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
this.push_task(task);
|
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule the fast local status pass, unless one is already pending.
|
||||||
|
///
|
||||||
|
/// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against
|
||||||
|
/// the local refs — no network — so a new commit in a checkout surfaces in
|
||||||
|
/// a second or two instead of at the next remote reconciliation.
|
||||||
|
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.local_pending {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.local_pending = true;
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(LOCAL_POLL).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.local_pending = false;
|
||||||
|
this.local_tick(cx);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fast local status pass.
|
||||||
|
///
|
||||||
|
/// Recomputes the statuses against the local refs; when the remote
|
||||||
|
/// reconciliation cadence elapsed, it runs a full pass instead so pushes
|
||||||
|
/// made elsewhere do not linger as `to push`.
|
||||||
|
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
||||||
|
// Nothing watched: the chain idles out until a new request restarts it.
|
||||||
|
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A full pass or a fresh request covers this tick, skip it.
|
||||||
|
if self.refresh.running() || self.refresh.debouncing() {
|
||||||
|
self.schedule_local_pass(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open panels get the faster remote cadence.
|
||||||
|
let cadence = if self.status_requested.is_empty() {
|
||||||
|
PUSH_POLL
|
||||||
|
} else {
|
||||||
|
STATUS_POLL
|
||||||
|
};
|
||||||
|
|
||||||
|
let full_due = self
|
||||||
|
.last_full_sync
|
||||||
|
.is_none_or(|sync| sync.elapsed() >= cadence);
|
||||||
|
|
||||||
|
if full_due {
|
||||||
|
self.last_full_sync = Some(Instant::now());
|
||||||
|
self.refresh(cx);
|
||||||
|
} else {
|
||||||
|
self.run_local_statuses(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.schedule_local_pass(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute the requested statuses against the tracking refs only.
|
||||||
|
///
|
||||||
|
/// The refs were last refreshed by a full pass. Comparing against them is
|
||||||
|
/// enough to pick up new local commits, and skipping the network keeps
|
||||||
|
/// this pass cheap enough to run every [`LOCAL_POLL`].
|
||||||
|
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let associations = self.by_repo.clone();
|
||||||
|
|
||||||
|
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||||
|
.status_requested
|
||||||
|
.iter()
|
||||||
|
.map(|addr| {
|
||||||
|
(
|
||||||
|
addr.clone(),
|
||||||
|
self.requested_head.get(addr).cloned().flatten(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let push_requested: Vec<RepoAddr> = self.push_requested.iter().cloned().collect();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move {
|
||||||
|
let (statuses, push_statuses) =
|
||||||
|
compute_statuses(&associations, &requested, &push_requested, false);
|
||||||
|
Ok::<_, Error>((statuses, push_statuses))
|
||||||
|
});
|
||||||
|
|
||||||
|
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
let Ok((statuses, push_statuses)) = work.await else {
|
||||||
|
// Git reads are best-effort, keep the last results.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
// A full pass or a fresh request will apply fresher data
|
||||||
|
// (the tracking refs move only when a full pass fetches).
|
||||||
|
if this.refresh.running() || this.refresh.debouncing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let statuses_changed = this.statuses != statuses;
|
||||||
|
let push_statuses_changed = this.push_statuses != push_statuses;
|
||||||
|
|
||||||
|
this.statuses = statuses;
|
||||||
|
this.push_statuses = push_statuses;
|
||||||
|
|
||||||
|
if statuses_changed || push_statuses_changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,67 +608,27 @@ fn resolve_associations<'a>(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the worktree of `path` has uncommitted changes.
|
|
||||||
fn worktree_dirty(path: &Path) -> bool {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(path)
|
|
||||||
.args(["status", "--porcelain"])
|
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
|
||||||
.output();
|
|
||||||
match output {
|
|
||||||
Ok(output) => !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Commits in `base..branch` of the checkout at `path`.
|
|
||||||
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(path)
|
|
||||||
.args(["rev-list", "--count", &format!("{base}..{branch}")])
|
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
|
||||||
.output();
|
|
||||||
match output {
|
|
||||||
Ok(output) => String::from_utf8_lossy(&output.stdout)
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(0),
|
|
||||||
Err(_) => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The branch checked out at `path`, read via `git branch --show-current`.
|
|
||||||
fn current_branch_of(path: &Path) -> Option<String> {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(path)
|
|
||||||
.args(["branch", "--show-current"])
|
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
|
||||||
(!branch.is_empty()).then_some(branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The ready-to-contribute status of one checkout.
|
/// The ready-to-contribute status of one checkout.
|
||||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||||
let branches = signed_git::worktree_branches(path).ok()?;
|
let branches = signed_git::worktree_branches(path).ok()?;
|
||||||
if branches.is_empty() || worktree_dirty(path) {
|
|
||||||
|
if branches.is_empty() || signed_git::worktree_dirty(path) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let branch = current_branch_of(path)?;
|
|
||||||
|
let branch = signed_git::worktree_current_branch(path)?;
|
||||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
||||||
let base = announced_head
|
let base = announced_head
|
||||||
.filter(|name| branches.iter().any(|b| b == name))
|
.filter(|name| branches.iter().any(|b| b == name))
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
|
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
|
||||||
.or_else(|| branches.first().cloned())?;
|
.or_else(|| branches.first().cloned())?;
|
||||||
|
|
||||||
if base == branch {
|
if base == branch {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let ahead = commits_ahead(path, &base, &branch);
|
|
||||||
|
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
|
||||||
(ahead > 0).then_some(CheckoutStatus {
|
(ahead > 0).then_some(CheckoutStatus {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
branch,
|
branch,
|
||||||
@@ -593,43 +638,40 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the reference `name` exists in the checkout at `path`.
|
|
||||||
///
|
|
||||||
/// Example, `refs/remotes/origin/main`.
|
|
||||||
fn ref_exists(path: &Path, name: &str) -> bool {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(path)
|
|
||||||
.args(["rev-parse", "--verify", "--quiet", name])
|
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
|
||||||
.output();
|
|
||||||
matches!(output, Ok(output) if output.status.success())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `ready to push` status of one checkout of the user's own repository.
|
/// The `ready to push` status of one checkout of the user's own repository.
|
||||||
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
///
|
||||||
if worktree_dirty(path) {
|
/// `fetch` refreshes the remote heads first, so a full pass sees pushes made
|
||||||
|
/// elsewhere; the fast local pass skips it and compares against the tracking
|
||||||
|
/// refs left by the last full pass, which is enough to detect local commits.
|
||||||
|
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||||
|
if signed_git::worktree_dirty(path) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let branch = current_branch_of(path)?;
|
|
||||||
|
let branch = signed_git::worktree_current_branch(path)?;
|
||||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
||||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||||
|
|
||||||
// Refresh the remote heads first.
|
if fetch {
|
||||||
// Commits made elsewhere or pushed from another machine must not linger as `to push`.
|
// Refresh the remote heads first.
|
||||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
// Commits made elsewhere or pushed from another machine must not linger as `to push`.
|
||||||
|
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
||||||
|
}
|
||||||
|
|
||||||
let remote = format!("refs/remotes/origin/{branch}");
|
let remote = format!("refs/remotes/origin/{branch}");
|
||||||
|
|
||||||
// A branch never fetched or pushed yet compares against the remote HEAD.
|
// A branch never fetched or pushed yet compares against the remote HEAD.
|
||||||
// The remote HEAD is the fork point in practice.
|
// The remote HEAD is the fork point in practice.
|
||||||
let base = if ref_exists(path, &remote) {
|
let base = if signed_git::worktree_ref_exists(path, &remote) {
|
||||||
remote
|
remote
|
||||||
} else if ref_exists(path, "refs/remotes/origin/HEAD") {
|
} else if signed_git::worktree_ref_exists(path, "refs/remotes/origin/HEAD") {
|
||||||
"refs/remotes/origin/HEAD".to_owned()
|
"refs/remotes/origin/HEAD".to_owned()
|
||||||
} else {
|
} else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let ahead = commits_ahead(path, &base, &branch);
|
|
||||||
|
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
|
||||||
|
|
||||||
(ahead > 0).then_some(CheckoutStatus {
|
(ahead > 0).then_some(CheckoutStatus {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
branch,
|
branch,
|
||||||
@@ -639,6 +681,57 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the requested statuses against the checkout paths of `associations`.
|
||||||
|
///
|
||||||
|
/// Shared by the full and the local pass. `fetch` refreshes the checkouts'
|
||||||
|
/// remote heads first, so the full pass sees remote moves; the fast local
|
||||||
|
/// pass reads the tracking refs only, which is enough to detect local commits.
|
||||||
|
fn compute_statuses(
|
||||||
|
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
|
requested: &[(RepoAddr, Option<String>)],
|
||||||
|
push_requested: &[RepoAddr],
|
||||||
|
fetch: bool,
|
||||||
|
) -> (
|
||||||
|
HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||||
|
HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||||
|
) {
|
||||||
|
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||||
|
for (addr, announced_head) in requested {
|
||||||
|
let Some(paths) = associations.get(addr) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let list: Vec<CheckoutStatus> = paths
|
||||||
|
.iter()
|
||||||
|
.take(MAX_STATUS_CHECKOUTS)
|
||||||
|
.filter_map(|path| checkout_status(path, announced_head.as_deref()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !list.is_empty() {
|
||||||
|
statuses.insert(addr.clone(), list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||||
|
for addr in push_requested {
|
||||||
|
let Some(paths) = associations.get(addr) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let list: Vec<CheckoutStatus> = paths
|
||||||
|
.iter()
|
||||||
|
.take(MAX_STATUS_CHECKOUTS)
|
||||||
|
.filter_map(|path| checkout_push_status(path, fetch))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !list.is_empty() {
|
||||||
|
push_statuses.insert(addr.clone(), list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(statuses, push_statuses)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the pull request `pr` already proposes the same change as `checkout`.
|
/// Whether the pull request `pr` already proposes the same change as `checkout`.
|
||||||
pub fn pr_proposes_checkout(
|
pub fn pr_proposes_checkout(
|
||||||
pr: &Event,
|
pr: &Event,
|
||||||
@@ -667,6 +760,8 @@ pub fn pr_proposes_checkout(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
use signed_core::{RepoAddr, repo_addr};
|
use signed_core::{RepoAddr, repo_addr};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -913,21 +1008,26 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A fresh clone has nothing to push.
|
// A fresh clone has nothing to push.
|
||||||
assert_eq!(checkout_push_status(&checkout), None);
|
assert_eq!(checkout_push_status(&checkout, true), None);
|
||||||
|
|
||||||
// One local commit, ready to push, counted against the remote.
|
// One local commit, ready to push, counted against the remote.
|
||||||
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
||||||
run(&["add", "-A"]);
|
run(&["add", "-A"]);
|
||||||
run(&["commit", "-m", "local work"]);
|
run(&["commit", "-m", "local work"]);
|
||||||
let status = checkout_push_status(&checkout).expect("status");
|
let status = checkout_push_status(&checkout, true).expect("status");
|
||||||
assert_eq!(status.branch, "main");
|
assert_eq!(status.branch, "main");
|
||||||
assert_eq!(status.base, "refs/remotes/origin/main");
|
assert_eq!(status.base, "refs/remotes/origin/main");
|
||||||
assert_eq!(status.ahead, 1);
|
assert_eq!(status.ahead, 1);
|
||||||
assert_eq!(status.head.len(), 40);
|
assert_eq!(status.head.len(), 40);
|
||||||
|
|
||||||
|
// The local-only pass reads the tracking refs, no fetch needed:
|
||||||
|
// a commit lands locally long before the remote is reconciled.
|
||||||
|
let local = checkout_push_status(&checkout, false).expect("local status");
|
||||||
|
assert_eq!(local.ahead, 1);
|
||||||
|
|
||||||
// After the push the same commit is on the remote, idle again.
|
// After the push the same commit is on the remote, idle again.
|
||||||
run(&["push", "origin", "main"]);
|
run(&["push", "origin", "main"]);
|
||||||
assert_eq!(checkout_push_status(&checkout), None);
|
assert_eq!(checkout_push_status(&checkout, true), None);
|
||||||
|
|
||||||
// A commit made by someone else on the remote must not count as local work.
|
// A commit made by someone else on the remote must not count as local work.
|
||||||
// It is behind, not ahead.
|
// It is behind, not ahead.
|
||||||
@@ -946,7 +1046,7 @@ mod tests {
|
|||||||
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
|
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
|
||||||
remote_run(&["add", "-A"]);
|
remote_run(&["add", "-A"]);
|
||||||
remote_run(&["commit", "-m", "remote work"]);
|
remote_run(&["commit", "-m", "remote work"]);
|
||||||
assert_eq!(checkout_push_status(&checkout), None);
|
assert_eq!(checkout_push_status(&checkout, true), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
|
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
|
||||||
|
|||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,7 @@
|
|||||||
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
|
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
|
||||||
/// re-query their inputs on a debounce timer with the same policy:
|
/// re-query their inputs on a debounce timer with the same policy:
|
||||||
/// a request arriving while a run is in flight is folded into a follow-up run,
|
/// a request arriving while a run is in flight is folded into a follow-up run,
|
||||||
/// a request arriving while a request debounce is pending is dropped by it.
|
/// a request arriving while the debounce timer is pending is dropped by it.
|
||||||
///
|
|
||||||
/// A slow poll cycle (`poll`) is different: it keeps a store's derived data
|
|
||||||
/// fresh while nothing is happening, but it must never delay a real request.
|
|
||||||
/// A request arriving while a poll is pending supersedes the poll with its own
|
|
||||||
/// short debounce, so external events (a push landing, a settings change)
|
|
||||||
/// propagate promptly instead of waiting out the poll interval.
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct RefreshGate {
|
pub struct RefreshGate {
|
||||||
/// A run is in flight.
|
/// A run is in flight.
|
||||||
@@ -18,10 +12,6 @@ pub struct RefreshGate {
|
|||||||
dirty: bool,
|
dirty: bool,
|
||||||
/// The debounce timer is pending.
|
/// The debounce timer is pending.
|
||||||
debouncing: bool,
|
debouncing: bool,
|
||||||
/// The pending debounce is a poll cycle, not a request.
|
|
||||||
///
|
|
||||||
/// Polls wait for a quiet moment; requests supersede them.
|
|
||||||
poll: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a refresh request decided.
|
/// What a refresh request decided.
|
||||||
@@ -44,54 +34,25 @@ impl RefreshGate {
|
|||||||
self.debouncing
|
self.debouncing
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether no run is in flight and no timer is pending.
|
|
||||||
pub fn idle(&self) -> bool {
|
|
||||||
!self.running && !self.debouncing
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A new refresh request arrived.
|
/// A new refresh request arrived.
|
||||||
///
|
///
|
||||||
/// Folded into a follow-up run while one is in flight or a request debounce
|
/// Folded into a follow-up run while one is in flight, dropped while the
|
||||||
/// is pending, superseding a slow poll with the request's own debounce,
|
/// debounce timer is pending, otherwise starts the timer.
|
||||||
/// otherwise starts the debounce timer.
|
|
||||||
pub fn request(&mut self) -> RefreshRequest {
|
pub fn request(&mut self) -> RefreshRequest {
|
||||||
if self.running {
|
if self.running {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
RefreshRequest::Fold
|
RefreshRequest::Fold
|
||||||
} else if self.debouncing && !self.poll {
|
} else if self.debouncing {
|
||||||
RefreshRequest::Fold
|
RefreshRequest::Fold
|
||||||
} else {
|
} else {
|
||||||
// A request supersedes a pending poll: start the short debounce.
|
|
||||||
self.debouncing = true;
|
self.debouncing = true;
|
||||||
self.poll = false;
|
|
||||||
RefreshRequest::Schedule
|
RefreshRequest::Schedule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A slow poll timer was started without a request.
|
|
||||||
pub fn poll(&mut self) {
|
|
||||||
self.debouncing = true;
|
|
||||||
self.poll = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A poll timer fired. Whether it is still the scheduled pass and may run.
|
|
||||||
///
|
|
||||||
/// A request that arrived while the poll was pending superseded it with its
|
|
||||||
/// own debounce, so the stale poll timer is skipped.
|
|
||||||
pub fn take_poll(&mut self) -> bool {
|
|
||||||
if self.debouncing && self.poll {
|
|
||||||
self.debouncing = false;
|
|
||||||
self.poll = false;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The debounce timer fired and the run starts now.
|
/// The debounce timer fired and the run starts now.
|
||||||
pub fn begin(&mut self) {
|
pub fn begin(&mut self) {
|
||||||
self.debouncing = false;
|
self.debouncing = false;
|
||||||
self.poll = false;
|
|
||||||
self.running = true;
|
self.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,83 +67,3 @@ impl RefreshGate {
|
|||||||
self.running = false;
|
self.running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{RefreshGate, RefreshRequest};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn request_starts_the_debounce_when_idle() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Schedule);
|
|
||||||
assert!(gate.debouncing());
|
|
||||||
assert!(!gate.idle());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn request_folds_into_a_request_debounce() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.request();
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Fold);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn request_folds_into_a_running_run_and_runs_again() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.request();
|
|
||||||
gate.begin();
|
|
||||||
assert!(gate.running());
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Fold);
|
|
||||||
assert!(gate.finish());
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn request_supersedes_a_pending_poll() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.poll();
|
|
||||||
assert!(gate.debouncing());
|
|
||||||
|
|
||||||
// The request starts its own short debounce instead of waiting out the poll.
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Schedule);
|
|
||||||
assert!(gate.debouncing());
|
|
||||||
assert!(
|
|
||||||
!gate.take_poll(),
|
|
||||||
"the superseded poll timer must be skipped"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The request's own debounce still fires.
|
|
||||||
gate.begin();
|
|
||||||
assert!(gate.running());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn poll_timer_runs_when_not_superseded() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.poll();
|
|
||||||
assert!(gate.take_poll());
|
|
||||||
assert!(gate.idle());
|
|
||||||
gate.begin();
|
|
||||||
assert!(gate.running());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn poll_keeps_scheduling_until_a_request_preempts() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.poll();
|
|
||||||
assert!(gate.take_poll());
|
|
||||||
gate.begin();
|
|
||||||
gate.finish();
|
|
||||||
gate.poll();
|
|
||||||
gate.request();
|
|
||||||
assert!(!gate.take_poll(), "preempted by the request");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn request_after_a_superseded_poll_is_folded_into_the_new_debounce() {
|
|
||||||
let mut gate = RefreshGate::default();
|
|
||||||
gate.poll();
|
|
||||||
gate.request();
|
|
||||||
assert_eq!(gate.request(), RefreshRequest::Fold);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+213
-73
@@ -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);
|
||||||
@@ -59,6 +60,10 @@ pub struct RepoStore {
|
|||||||
///
|
///
|
||||||
/// Example, a PR published without its commit reaching a grasp server.
|
/// Example, a PR published without its commit reaching a grasp server.
|
||||||
pub last_warning: Option<String>,
|
pub last_warning: Option<String>,
|
||||||
|
/// Warning of the last push that only some grasp servers accepted.
|
||||||
|
///
|
||||||
|
/// The repository is out of sync on the rejected servers until it is republished.
|
||||||
|
pub last_push_warning: Option<String>,
|
||||||
/// A republish or a checkout push is in flight.
|
/// A republish or a checkout push is in flight.
|
||||||
///
|
///
|
||||||
/// Views show a spinner and disable their push triggers while it is set.
|
/// Views show a spinner and disable their push triggers while it is set.
|
||||||
@@ -77,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,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;
|
||||||
@@ -103,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;
|
||||||
@@ -123,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,
|
||||||
@@ -137,22 +154,14 @@ impl RepoStore {
|
|||||||
version: 0,
|
version: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
last_warning: None,
|
last_warning: None,
|
||||||
|
last_push_warning: None,
|
||||||
pushing: false,
|
pushing: false,
|
||||||
cloning: false,
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
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.
|
||||||
@@ -224,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>) {
|
||||||
@@ -367,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,
|
||||||
@@ -461,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.
|
||||||
@@ -509,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.
|
||||||
@@ -540,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,
|
||||||
);
|
);
|
||||||
@@ -633,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(
|
||||||
@@ -793,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| {
|
||||||
@@ -808,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 {
|
||||||
@@ -817,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.
|
||||||
@@ -889,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,
|
||||||
@@ -908,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,
|
||||||
@@ -921,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.
|
||||||
@@ -977,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.
|
||||||
@@ -997,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());
|
||||||
@@ -1035,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| {
|
||||||
@@ -1057,7 +1145,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest announcement of this repository,
|
/// The latest announcement of this repository,
|
||||||
@@ -1086,6 +1175,7 @@ impl RepoStore {
|
|||||||
|
|
||||||
self.pushing = true;
|
self.pushing = true;
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
|
self.last_push_warning = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
@@ -1097,14 +1187,23 @@ impl RepoStore {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.pushing = false;
|
this.pushing = false;
|
||||||
|
|
||||||
if let Err(e) = &result {
|
match &result {
|
||||||
this.last_error = Some(format!("Push failed: {e}"));
|
Ok(outcome) => {
|
||||||
|
this.last_error = None;
|
||||||
|
// A push only some grasp servers accepted is a warning:
|
||||||
|
// the repo is out of sync on the rest until it is republished.
|
||||||
|
this.last_push_warning = outcome.partial_warning();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.last_error = Some(format!("Push failed: {e}"));
|
||||||
|
this.last_push_warning = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
result
|
result.map(|_| ())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,6 +1230,7 @@ impl RepoStore {
|
|||||||
|
|
||||||
self.pushing = true;
|
self.pushing = true;
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
|
self.last_push_warning = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
@@ -1146,7 +1246,11 @@ impl RepoStore {
|
|||||||
this.pushing = false;
|
this.pushing = false;
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(()) => {
|
Ok(outcome) => {
|
||||||
|
this.last_error = None;
|
||||||
|
// A push only some grasp servers accepted is a warning:
|
||||||
|
// the repo is out of sync on the rest until it is republished.
|
||||||
|
this.last_push_warning = outcome.partial_warning();
|
||||||
// The remote moved, so recompute the ready-to-push statuses.
|
// The remote moved, so recompute the ready-to-push statuses.
|
||||||
checkouts.update(cx, |store, cx| {
|
checkouts.update(cx, |store, cx| {
|
||||||
store.checkout_pushed(&addr, &path, cx);
|
store.checkout_pushed(&addr, &path, cx);
|
||||||
@@ -1154,13 +1258,14 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.last_error = Some(format!("Push failed: {e}"));
|
this.last_error = Some(format!("Push failed: {e}"));
|
||||||
|
this.last_push_warning = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
result
|
result.map(|_| ())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,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;
|
||||||
@@ -1307,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1405,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;
|
||||||
|
|
||||||
@@ -1445,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(_)));
|
||||||
|
|
||||||
@@ -492,16 +488,8 @@ impl RepoDetailView {
|
|||||||
if refresh_generation != this.ref_generation {
|
if refresh_generation != this.ref_generation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
|
|
||||||
if moved {
|
|
||||||
// The mirror caught up with the remote.
|
|
||||||
// E.g. the push of an owned checkout just landed.
|
|
||||||
// Rebuild the explorer, previews and commit list from the worktree.
|
|
||||||
this.reload_worktree(cx);
|
|
||||||
cx.notify();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
|
||||||
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
|
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
|
||||||
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
|
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
|
||||||
|
|
||||||
@@ -528,6 +516,10 @@ impl RepoDetailView {
|
|||||||
this.load_all_commits(cx);
|
this.load_all_commits(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if moved {
|
||||||
|
this.catch_up_worktree(cx);
|
||||||
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -535,7 +527,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the loaded repository data.
|
/// Apply the loaded repository data.
|
||||||
@@ -623,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 {
|
||||||
@@ -653,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.
|
||||||
@@ -707,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 {
|
||||||
@@ -776,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.
|
||||||
@@ -808,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(
|
||||||
@@ -838,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.
|
||||||
@@ -856,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;
|
||||||
@@ -880,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`.
|
||||||
@@ -913,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`.
|
||||||
@@ -935,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))
|
||||||
@@ -952,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.
|
||||||
@@ -960,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.
|
||||||
@@ -1029,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))
|
||||||
@@ -1064,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.
|
||||||
@@ -1105,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 {
|
||||||
@@ -1135,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.
|
||||||
@@ -1161,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)?;
|
||||||
@@ -1222,7 +1216,120 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the file explorer, previews and commit list after the mirror
|
||||||
|
/// caught up with the remote.
|
||||||
|
///
|
||||||
|
/// The checked-out branch fast-forwarded in place, so unlike
|
||||||
|
/// [`Self::reload_worktree`] this keeps the panel's selection and previews:
|
||||||
|
/// it rebuilds the tree, drops previews of files the refresh removed and
|
||||||
|
/// re-renders the README when it is on screen.
|
||||||
|
fn catch_up_worktree(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(worktree) = self.worktree.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_spawn(async move {
|
||||||
|
let snapshot = signed_git::worktree_snapshot(&worktree)?;
|
||||||
|
let tree = build_tree_items(&snapshot.entries);
|
||||||
|
Ok::<_, Error>((snapshot, tree))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
match result {
|
||||||
|
Ok((snapshot, tree)) => {
|
||||||
|
let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id)
|
||||||
|
!= this.head_commit.as_ref().map(|c| &c.id);
|
||||||
|
|
||||||
|
this.head_commit = snapshot.head_commit;
|
||||||
|
this.tree_state.update(cx, |state, cx| {
|
||||||
|
state.set_items(tree_items(tree, false), cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop previews of files the refresh removed from the worktree,
|
||||||
|
// everything else stays put.
|
||||||
|
let present: HashSet<String> = snapshot
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut previewed: Vec<String> = Vec::new();
|
||||||
|
previewed.extend(this.files.keys().cloned());
|
||||||
|
previewed.extend(this.selected_file.clone().map(|p| p.to_string()));
|
||||||
|
|
||||||
|
if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) {
|
||||||
|
previewed.push(path.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) {
|
||||||
|
previewed.push(path.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
previewed.sort();
|
||||||
|
previewed.dedup();
|
||||||
|
|
||||||
|
for path in previewed {
|
||||||
|
if !present.contains(&path) {
|
||||||
|
this.drop_preview_of(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render the README when it is on screen, i.e. when no file preview is open.
|
||||||
|
if this.selected_file.is_none() {
|
||||||
|
match snapshot.readme_path.zip(snapshot.readme) {
|
||||||
|
Some((path, bytes)) => {
|
||||||
|
this.readme_name = Some(path.to_string_lossy().into());
|
||||||
|
if let Ok(text) = String::from_utf8(bytes) {
|
||||||
|
this.set_markdown(None, &text, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
this.md = None;
|
||||||
|
this.readme_name = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if head_changed {
|
||||||
|
this.all_commits = None;
|
||||||
|
this.loading_all_commits = false;
|
||||||
|
this.load_all_commits(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.error = Some(error.to_string().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cached preview, editor and commit state of `path`.
|
||||||
|
fn drop_preview_of(&mut self, path: &str) {
|
||||||
|
if let Some(FileContent::Text(text)) = self.files.remove(path) {
|
||||||
|
self.preview_bytes -= text.len();
|
||||||
|
}
|
||||||
|
self.commits.remove(path);
|
||||||
|
if self.selected_file.as_deref() == Some(path) {
|
||||||
|
self.selected_file = None;
|
||||||
|
}
|
||||||
|
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
|
||||||
|
self.md = None;
|
||||||
|
}
|
||||||
|
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
|
||||||
|
self.code = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the oldest previews beyond the cache caps.
|
/// Drop the oldest previews beyond the cache caps.
|
||||||
@@ -1992,10 +2099,75 @@ impl RepoDetailView {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Warning after a push that only some grasp servers accepted.
|
||||||
|
fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||||
|
let store = self.store.as_ref()?;
|
||||||
|
let store = store.read(cx);
|
||||||
|
let warning = store.last_push_warning.clone()?;
|
||||||
|
let pushing = store.pushing;
|
||||||
|
|
||||||
|
Some(
|
||||||
|
h_flex()
|
||||||
|
.p_4()
|
||||||
|
.gap_2()
|
||||||
|
.w_full()
|
||||||
|
.items_start()
|
||||||
|
.justify_between()
|
||||||
|
.bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.min_w_0()
|
||||||
|
.flex_1()
|
||||||
|
.items_start()
|
||||||
|
.child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().warning)
|
||||||
|
.child(SharedString::from(warning)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.child(
|
||||||
|
Button::new("republish-after-partial-push")
|
||||||
|
.icon(CustomIconName::Init)
|
||||||
|
.label("Republish")
|
||||||
|
.small()
|
||||||
|
.info()
|
||||||
|
.loading(pushing)
|
||||||
|
.disabled(pushing)
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.push_repository(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("dismiss-push-warning")
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.tooltip("Dismiss")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.disabled(pushing)
|
||||||
|
.on_click(cx.listener(|this, _ev, _window, cx| {
|
||||||
|
if let Some(store) = this.store.clone() {
|
||||||
|
store.update(cx, |store, _| {
|
||||||
|
store.last_push_warning = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The ready-to-contribute banner of the repository panel.
|
/// The ready-to-contribute banner of the repository panel.
|
||||||
///
|
|
||||||
/// A checkout has commits ahead of its base branch, with a Create action
|
|
||||||
/// opening the prefilled New PR panel, and a dismiss control.
|
|
||||||
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||||
let status = self.ready_suggestion(cx)?;
|
let status = self.ready_suggestion(cx)?;
|
||||||
let key = (status.path.clone(), status.branch.clone());
|
let key = (status.path.clone(), status.branch.clone());
|
||||||
@@ -2324,6 +2496,9 @@ impl Render for RepoDetailView {
|
|||||||
.id("repo")
|
.id("repo")
|
||||||
.size_full()
|
.size_full()
|
||||||
.when_some(banner, |this, banner| this.child(banner))
|
.when_some(banner, |this, banner| this.child(banner))
|
||||||
|
.when_some(self.render_push_warning_banner(cx), |this, banner| {
|
||||||
|
this.child(banner)
|
||||||
|
})
|
||||||
.child(self.render_header(cx))
|
.child(self.render_header(cx))
|
||||||
.when_some(error, |this, error| {
|
.when_some(error, |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ publish.workspace = true
|
|||||||
name = "signed"
|
name = "signed"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[package.metadata.packager]
|
||||||
|
name = "Signed"
|
||||||
|
product-name = "Signed"
|
||||||
|
description = "Nostr Git client for browsing repositories and collaborating"
|
||||||
|
identifier = "su.reya.signed"
|
||||||
|
category = "DeveloperTool"
|
||||||
|
version = "0.1.0-alpha"
|
||||||
|
out-dir = "../dist"
|
||||||
|
before-packaging-command = "cargo build --release"
|
||||||
|
resources = ["Cargo.toml", "src"]
|
||||||
|
icons = [
|
||||||
|
"resources/32x32.png",
|
||||||
|
"resources/128x128.png",
|
||||||
|
"resources/128x128@2x.png",
|
||||||
|
"resources/icon.icns",
|
||||||
|
"resources/icon.ico",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
assets = { path = "../crates/assets" }
|
assets = { path = "../crates/assets" }
|
||||||
paths = { path = "../crates/paths" }
|
paths = { path = "../crates/paths" }
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"id": "$APP_ID",
|
||||||
|
"runtime": "org.freedesktop.Platform",
|
||||||
|
"runtime-version": "24.08",
|
||||||
|
"sdk": "org.freedesktop.Sdk",
|
||||||
|
"sdk-extensions": ["org.freedesktop.Sdk.Extension.rust-stable"],
|
||||||
|
"command": "signed",
|
||||||
|
"finish-args": [
|
||||||
|
"--talk-name=org.freedesktop.Flatpak",
|
||||||
|
"--device=dri",
|
||||||
|
"--share=ipc",
|
||||||
|
"--share=network",
|
||||||
|
"--socket=wayland",
|
||||||
|
"--socket=fallback-x11",
|
||||||
|
"--socket=pulseaudio",
|
||||||
|
"--filesystem=host"
|
||||||
|
],
|
||||||
|
"build-options": {
|
||||||
|
"append-path": "/usr/lib/sdk/rust-stable/bin"
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "signed",
|
||||||
|
"buildsystem": "simple",
|
||||||
|
"build-options": {
|
||||||
|
"env": {
|
||||||
|
"APP_ID": "$APP_ID",
|
||||||
|
"APP_ICON": "$APP_ID",
|
||||||
|
"APP_NAME": "$APP_NAME",
|
||||||
|
"BRANDING_LIGHT": "$BRANDING_LIGHT",
|
||||||
|
"BRANDING_DARK": "$BRANDING_DARK",
|
||||||
|
"APP_CLI": "signed",
|
||||||
|
"APP_ARGS": "--foreground %U",
|
||||||
|
"DO_STARTUP_NOTIFY": "false"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build-commands": [
|
||||||
|
"install -Dm644 $ICON_FILE.png /app/share/icons/hicolor/512x512/apps/$APP_ID.png",
|
||||||
|
"envsubst < signed.desktop.in > signed.desktop && install -Dm644 signed.desktop /app/share/applications/$APP_ID.desktop",
|
||||||
|
"envsubst < flatpak/signed.metainfo.xml.in > signed.metainfo.xml && install -Dm644 signed.metainfo.xml /app/share/metainfo/$APP_ID.metainfo.xml",
|
||||||
|
"sed -i -e '/@release_info@/{r flatpak/release-info/$CHANNEL' -e 'd}' /app/share/metainfo/$APP_ID.metainfo.xml",
|
||||||
|
"install -Dm755 bin/signed /app/bin/signed",
|
||||||
|
"install -Dm755 libexec/signed /app/libexec/signed",
|
||||||
|
"install -Dm755 lib/* -t /app/lib"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"path": "./target/release/$ARCHIVE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "dir",
|
||||||
|
"path": "./desktop/resources"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>$APP_ID</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>GPL-3.0-or-later</project_license>
|
||||||
|
|
||||||
|
<name>$APP_NAME</name>
|
||||||
|
<summary>GPUI desktop Git client</summary>
|
||||||
|
|
||||||
|
<developer id="su.reya">
|
||||||
|
<name translate="no">Ren Amamiya</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Signed is a desktop Git client built with GPUI.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Browse repositories and their history, review commits and diffs, and
|
||||||
|
collaborate through issues, pull requests, and patches.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">$APP_ID.desktop</launchable>
|
||||||
|
|
||||||
|
<branding>
|
||||||
|
<color type="primary" scheme_preference="light">$BRANDING_LIGHT</color>
|
||||||
|
<color type="primary" scheme_preference="dark">$BRANDING_DARK</color>
|
||||||
|
</branding>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1"/>
|
||||||
|
|
||||||
|
<url type="homepage">https://git.reya.su/reya/signed</url>
|
||||||
|
<url type="bugtracker">https://git.reya.su/reya/signed/issues</url>
|
||||||
|
<url type="faq">https://git.reya.su/reya/signed</url>
|
||||||
|
<url type="help">https://git.reya.su/reya/signed/issues</url>
|
||||||
|
<url type="contact">https://reya.su/</url>
|
||||||
|
<url type="vcs-browser">https://git.reya.su/reya/signed</url>
|
||||||
|
|
||||||
|
<recommends>
|
||||||
|
<control>pointing</control>
|
||||||
|
<control>keyboard</control>
|
||||||
|
<display_length compare="ge">768</display_length>
|
||||||
|
</recommends>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
@release_info@
|
||||||
|
<release version="0.0.0" date="1970-01-01">
|
||||||
|
<description>
|
||||||
|
<p>Dummy release to keep flatpak-builder AppStream metadata validation from complaining</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=$APP_NAME
|
||||||
|
GenericName=Nostr Git Client
|
||||||
|
Comment=Nostr Git client for browsing repositories and collaborating
|
||||||
|
TryExec=$APP_CLI
|
||||||
|
StartupNotify=$DO_STARTUP_NOTIFY
|
||||||
|
Exec=$APP_CLI $APP_ARGS
|
||||||
|
Icon=$APP_ICON
|
||||||
|
Categories=Development;RevisionControl;
|
||||||
|
Keywords=git;repository;diff;commit;pull-request;patch;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: signed
|
||||||
|
title: Signed
|
||||||
|
base: core24
|
||||||
|
version: "$RELEASE_VERSION"
|
||||||
|
summary: GPUI desktop Git client for browsing repositories and collaborating
|
||||||
|
description: |
|
||||||
|
Browse repositories and their history, review commits and diffs, and
|
||||||
|
collaborate through issues, pull requests, and patches.
|
||||||
|
grade: stable
|
||||||
|
confinement: classic
|
||||||
|
compression: lzo
|
||||||
|
website: https://git.reya.su/reya/signed
|
||||||
|
source-code: https://git.reya.su/reya/signed
|
||||||
|
issues: https://git.reya.su/reya/signed/issues
|
||||||
|
contact: https://reya.su
|
||||||
|
|
||||||
|
parts:
|
||||||
|
signed:
|
||||||
|
plugin: dump
|
||||||
|
source: target/release/signed-linux-$ARCH_SUFFIX.tar.gz
|
||||||
|
|
||||||
|
organize:
|
||||||
|
# These renames seem to not be necessary, but it's tidier.
|
||||||
|
bin: usr/bin
|
||||||
|
libexec: usr/libexec
|
||||||
|
|
||||||
|
stage-packages:
|
||||||
|
- libasound2t64
|
||||||
|
# snapcraft has a lint that this is unused, but without it Signed exits with
|
||||||
|
# "Missing Vulkan entry points: LibraryLoadFailure" in blade_graphics.
|
||||||
|
- libvulkan1
|
||||||
|
# snapcraft has a lint that this is unused, but without it Signed exits with
|
||||||
|
# "NoWaylandLib" when run with Wayland.
|
||||||
|
- libwayland-client0
|
||||||
|
- libxcb1
|
||||||
|
- libxkbcommon-x11-0
|
||||||
|
- libxkbcommon0
|
||||||
|
|
||||||
|
build-attributes:
|
||||||
|
- enable-patchelf
|
||||||
|
|
||||||
|
prime:
|
||||||
|
# Omit unneeded files from the tarball
|
||||||
|
- -lib
|
||||||
|
- -licenses.md
|
||||||
|
- -share
|
||||||
|
|
||||||
|
# Omit unneeded files from stage-packages
|
||||||
|
- -etc
|
||||||
|
- -usr/share/doc
|
||||||
|
- -usr/share/lintian
|
||||||
|
- -usr/share/man
|
||||||
|
|
||||||
|
apps:
|
||||||
|
signed:
|
||||||
|
command: usr/bin/signed
|
||||||
|
common-id: su.reya.signed
|
||||||
-173
@@ -1,173 +0,0 @@
|
|||||||
# Pull request flow
|
|
||||||
|
|
||||||
How a pull request moves through Signed from creation to merge. A PR is a
|
|
||||||
kind-1618 root event whose content is the markdown description; its changes
|
|
||||||
live in a NIP-10-chained series of kind-1617 patch events (one per commit),
|
|
||||||
whose root the PR references via an `e` tag. Revisions publish new patch
|
|
||||||
events plus kind-1619 updates; statuses (kind 1630-1633) resolve the PR's
|
|
||||||
state.
|
|
||||||
|
|
||||||
## Whole lifecycle
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A["New pull request panel"] --> B{"Compare source"}
|
|
||||||
B -->|"Local checkout"| C["Pick folder (or auto-prefilled from remembered checkouts)"]
|
|
||||||
B -->|"Announced fork"| D["Pick fork repo + branch"]
|
|
||||||
D --> D1["Ensure base mirror (GitCache), fetch origin"]
|
|
||||||
D1 --> D2["Import fork heads as refs/fork/<owner>/<id>/*"]
|
|
||||||
C --> E["Defaults: target = announced HEAD, source = current branch / fork main"]
|
|
||||||
E --> F["merge-base + commits + diff of target..source (Files/Commits tabs)"]
|
|
||||||
F --> H["Submit: format-patch base..tip at publish time"]
|
|
||||||
H --> I["split_patch_series: one part per commit"]
|
|
||||||
I --> J{"Any part over 60 KB?"}
|
|
||||||
J -->|"Yes"| K["Refuse with message"]
|
|
||||||
J -->|"No"| L["tip = last part's From commit"]
|
|
||||||
L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"]
|
|
||||||
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base, clone"]
|
|
||||||
N --> O["Sign early - learn the event id"]
|
|
||||||
O --> P["Push tip to refs/nostr/event-id: author /prs/ grasp servers first, then the announced servers"]
|
|
||||||
P -->|"All rejected"| Q["last_warning banner in PR list"]
|
|
||||||
P --> R["Publish kind-1618 PR event"]
|
|
||||||
Q --> R
|
|
||||||
R --> S{"Draft?"}
|
|
||||||
S -->|"Yes"| T["Publish kind-1633 draft status"]
|
|
||||||
S -->|"No"| U["PR open"]
|
|
||||||
T --> U
|
|
||||||
U --> V{"Author updates?"}
|
|
||||||
V -->|"Yes"| W["Publish revision patch series: first has t root-revision and e-replies to the original root"]
|
|
||||||
W --> X["Publish kind-1619 update: E/P NIP-22 tags, c = new tip"]
|
|
||||||
X --> U
|
|
||||||
V -->|"No"| Y{"Repository author merges?"}
|
|
||||||
Y -->|"Yes"| Z["Apply the series with git am on the mirror clone"]
|
|
||||||
Z --> AA["applied = rev-list previous-head..HEAD"]
|
|
||||||
AA --> AB["Publish kind-1631 applied status: applied-as-commits plus r per commit, q plus e-reply per patch event"]
|
|
||||||
AB --> AC["PR merged"]
|
|
||||||
Y -->|"Close instead"| AD["Publish kind-1632 closed status"]
|
|
||||||
AD --> AE["PR closed"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Key points of the write side:
|
|
||||||
|
|
||||||
- **Compare sources** (NIP-34 / GRASP-06 native, no fork identity on the
|
|
||||||
wire):
|
|
||||||
- *Local checkout*: both branch selectors list a picked folder's
|
|
||||||
branches; all git ops run in that folder. Checkouts of the target repo
|
|
||||||
are remembered (folder pick + app clones) and matched implicitly
|
|
||||||
(origin URL or EUC against the announcement), so the panel prefills the
|
|
||||||
freshest one - no folder dialog for the common case.
|
|
||||||
- *Announced fork*: the fork's heads are fetched into the target repo's
|
|
||||||
GitCache mirror under `refs/fork/<owner-hex>/<id>/*` (private
|
|
||||||
namespace; the browser never sees them). "Merge Into" lists the
|
|
||||||
mirror's `refs/remotes/origin/*`, "Pull From" the imported fork
|
|
||||||
branches, and every git op - merge-base, range diff/commits,
|
|
||||||
format-patch, tip push - runs in the mirror, which holds both
|
|
||||||
histories. Fork candidates are announcements related to the target by
|
|
||||||
`u` tag or shared EUC, own forks first, without `clone` URLs excluded.
|
|
||||||
- **GRASP-06 hosting**: the tip is pushed under `refs/nostr/<event-id>`
|
|
||||||
(nak's convention) to the *author's* grasp servers first -
|
|
||||||
`https://<host>/prs/<author-npub>/<repo-id>.git`, resolved from the
|
|
||||||
author's kind-10317 grasp list, falling back to the settings defaults -
|
|
||||||
then to the base repository's announced grasp servers. The `clone` tag
|
|
||||||
lists those `/prs/` URLs first, then the announced clone URLs (fixed
|
|
||||||
before signing; dead URLs are inert, the patches stay the source of
|
|
||||||
truth). Contributing therefore never depends on the other project's
|
|
||||||
servers accepting a push.
|
|
||||||
- **Patch series**: each commit becomes its own kind-1617 event so no event
|
|
||||||
grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last*
|
|
||||||
commit of the series (the tip), and each part carries its own
|
|
||||||
`commit`/`r` tags.
|
|
||||||
- **Push before publish**: failure is non-fatal - the patch events remain
|
|
||||||
the source of truth - and surfaces as a `last_warning` banner.
|
|
||||||
- **1619 updates are paste-only today** (no repo path holds the new tip's
|
|
||||||
objects), so updates are not pushed; hosting them is deferred until the
|
|
||||||
update dialog gains a local-checkout source.
|
|
||||||
|
|
||||||
## Creating a pull request - event ordering
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant P as Base mirror (GitCache)
|
|
||||||
participant F as Fork grasp server
|
|
||||||
participant A as Author grasp (GRASP-06 /prs/)
|
|
||||||
participant B as Base repo grasps
|
|
||||||
participant R as Nostr relays
|
|
||||||
|
|
||||||
User->>P: ensure mirror (fork mode) / pick local checkout
|
|
||||||
P-->>F: fetch fork heads -> refs/fork/... (fork mode)
|
|
||||||
User->>P: merge-base, range commits, range diff
|
|
||||||
User->>P: submit: format-patch base..compare-ref
|
|
||||||
loop each patch of the series
|
|
||||||
User->>R: publish kind-1617 (first: t root, later: e reply)
|
|
||||||
end
|
|
||||||
User->>User: build and sign kind-1618 (clone = /prs/ URLs + announced)
|
|
||||||
User->>A: push tip to refs/nostr/event-id (author servers, first)
|
|
||||||
User->>B: push tip to refs/nostr/event-id (best-effort)
|
|
||||||
A-->>User: accepted or rejected (all rejected -> warning)
|
|
||||||
User->>R: publish kind-1618 PR event
|
|
||||||
opt draft
|
|
||||||
User->>R: publish kind-1633 draft status
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ready to contribute (suggestions)
|
|
||||||
|
|
||||||
Local checkouts are matched to announced repositories (remembered records
|
|
||||||
freshest-first ∪ scanned matches by origin URL or EUC). While a repository's
|
|
||||||
detail panel is open, each associated checkout is checked off the main
|
|
||||||
thread: current branch vs its base (announced HEAD, else `main`, else the
|
|
||||||
first branch), commits ahead, dirty worktrees excluded. A banner in the
|
|
||||||
repository panel then offers a prefilled New PR panel for the first branch
|
|
||||||
that is ahead with **no open PR by you** proposing it (`branch-name` tag,
|
|
||||||
falling back to the `c` tip tag) - NIP-34-native dedupe, refreshed
|
|
||||||
periodically and whenever the checkouts/announcements change. The panel
|
|
||||||
never submits anything on its own; suggestions only navigate and prefill.
|
|
||||||
|
|
||||||
## Updating and merging
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Author
|
|
||||||
participant Relays as Nostr relays
|
|
||||||
participant Maintainer
|
|
||||||
participant Clone as Mirror clone
|
|
||||||
|
|
||||||
Note over Author,Relays: Update - PR author only (paste flow, no push yet)
|
|
||||||
Author->>Relays: publish revision patch series (t root-revision, e reply to original root)
|
|
||||||
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip)
|
|
||||||
|
|
||||||
Note over Maintainer,Clone: Merge - repository author only (store-only today)
|
|
||||||
Maintainer->>Clone: git am the patch series
|
|
||||||
Clone-->>Maintainer: applied commits (rev-list previous-head..HEAD)
|
|
||||||
Maintainer->>Relays: publish kind-1631 applied status
|
|
||||||
Note over Relays: applied-as-commits and r per commit, q and e-reply per applied patch event
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reading side
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A["PR root kind-1618"] --> B{"Newest status event by author or maintainer?"}
|
|
||||||
B -->|"1633"| C["Draft"]
|
|
||||||
B -->|"1631"| D["Applied / merged"]
|
|
||||||
B -->|"1632"| E["Closed"]
|
|
||||||
B -->|"1630 or none"| F["Open"]
|
|
||||||
A --> G{"Newest kind-1619 update by PR author?"}
|
|
||||||
G -->|"Yes"| H["tip = update's c tag"]
|
|
||||||
G -->|"No"| I["tip = root's c tag"]
|
|
||||||
A --> J{"Patch set present?"}
|
|
||||||
J -->|"Yes"| K["Root patch via e tag, follow reply chain (newest wins per revision)"]
|
|
||||||
J -->|"No"| L["Diff merge-base..tip from the git clone"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Reader rules that keep the flow consistent:
|
|
||||||
|
|
||||||
- **Status**: only status events by the root author or a repository
|
|
||||||
maintainer count; the newest wins, `Open` is the default.
|
|
||||||
- **Tip**: only kind-1619 updates by the PR author move the tip - a
|
|
||||||
stranger's update is ignored.
|
|
||||||
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
|
|
||||||
other clients without patch events fall back to diffing
|
|
||||||
`merge-base..tip` in the local clone. Fetching tips from `clone` URLs
|
|
||||||
(ngit `pr checkout` analog) is not implemented yet.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
# Generated files - do not commit to main repo
|
||||||
|
# These are generated by script/prepare-flathub
|
||||||
|
vendor/
|
||||||
|
vendor.tar.gz
|
||||||
|
su.reya.signed.yml
|
||||||
|
su.reya.signed.metainfo.xml
|
||||||
|
release-info.xml
|
||||||
|
cargo-config.toml
|
||||||
|
build/
|
||||||
|
repo/
|
||||||
|
|
||||||
|
# Keep the README and this .gitignore
|
||||||
|
!README.md
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Flathub Submission for Signed
|
||||||
|
|
||||||
|
This directory contains the files needed to submit Signed to Flathub.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Flatpak installed
|
||||||
|
- `flatpak-builder` installed
|
||||||
|
- Rust/Cargo installed (for vendoring)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Run the preparation script from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./script/prepare-flathub
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Vendor all Rust dependencies (crates.io + git)
|
||||||
|
2. Generate the metainfo.xml with proper release info
|
||||||
|
3. Create `su.reya.signed.yml` - the Flatpak manifest for Flathub
|
||||||
|
|
||||||
|
## Files Generated
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `su.reya.signed.yml` | Main Flatpak manifest (submit this to Flathub) |
|
||||||
|
| `su.reya.signed.metainfo.xml` | AppStream metadata with release info |
|
||||||
|
| `vendor.tar.gz` | Vendored Rust dependencies |
|
||||||
|
| `cargo-config.toml` | Cargo configuration for offline builds |
|
||||||
|
| `release-info.xml` | Release info snippet for metainfo |
|
||||||
|
|
||||||
|
## Testing Locally
|
||||||
|
|
||||||
|
Before submitting to Flathub, test the build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd flathub
|
||||||
|
|
||||||
|
# Build and install locally
|
||||||
|
flatpak-builder --user --install --force-clean build su.reya.signed.yml
|
||||||
|
|
||||||
|
# Test the app
|
||||||
|
flatpak run su.reya.signed
|
||||||
|
|
||||||
|
# Run the Flathub linter (must pass!)
|
||||||
|
flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest su.reya.signed.yml
|
||||||
|
flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Submitting to Flathub
|
||||||
|
|
||||||
|
### 1. Prepare Your Release
|
||||||
|
|
||||||
|
Ensure you have:
|
||||||
|
- [ ] Committed all changes
|
||||||
|
- [ ] Tagged the release: `git tag -a v0.1.0 -m "Release v0.1.0"`
|
||||||
|
- [ ] Pushed the tag: `git push origin v0.1.0`
|
||||||
|
- [ ] Run `./script/prepare-flathub` to regenerate files
|
||||||
|
|
||||||
|
### 2. Fork and Submit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fork https://github.com/flathub/flathub on GitHub first
|
||||||
|
|
||||||
|
# Clone your fork (use the new-pr branch!)
|
||||||
|
git clone --branch=new-pr git@github.com:YOUR_USERNAME/flathub.git
|
||||||
|
cd flathub
|
||||||
|
|
||||||
|
# Create a new branch
|
||||||
|
git checkout -b su.reya.signed
|
||||||
|
|
||||||
|
# Copy ONLY the manifest file from your project
|
||||||
|
cp /path/to/signed/flathub/su.reya.signed.yml .
|
||||||
|
|
||||||
|
# Commit and push
|
||||||
|
git add su.reya.signed.yml
|
||||||
|
git commit -m "Add su.reya.signed"
|
||||||
|
git push origin su.reya.signed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Open Pull Request
|
||||||
|
|
||||||
|
1. Go to your fork on GitHub
|
||||||
|
2. Click "Compare & pull request"
|
||||||
|
3. **Important:** Set base branch to `new-pr` (not `master`!)
|
||||||
|
4. Fill in the PR template
|
||||||
|
5. Submit and wait for review
|
||||||
|
|
||||||
|
## What Happens Next?
|
||||||
|
|
||||||
|
1. Flathub's automated CI will build your app
|
||||||
|
2. A maintainer will review your submission
|
||||||
|
3. Once approved, a new repo `flathub/su.reya.signed` will be created
|
||||||
|
4. You'll get write access to maintain the app
|
||||||
|
5. Future updates: Push new commits to `flathub/su.reya.signed`
|
||||||
|
|
||||||
|
## Updating the App
|
||||||
|
|
||||||
|
To release a new version:
|
||||||
|
|
||||||
|
1. Update version in workspace `Cargo.toml`
|
||||||
|
2. Tag the new release: `git tag -a v0.1.0 -m "Release v0.1.0"`
|
||||||
|
3. Push the tag: `git push origin v0.1.0`
|
||||||
|
4. Run `./script/prepare-flathub` to regenerate
|
||||||
|
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.signed.git`
|
||||||
|
6. Update the manifest with new commit/tag and hashes
|
||||||
|
7. Submit PR to `flathub/su.reya.signed`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build fails with "network access not allowed"
|
||||||
|
- Make sure `CARGO_NET_OFFLINE=true` is set in the manifest
|
||||||
|
- Ensure `vendor.tar.gz` is properly extracted before building
|
||||||
|
|
||||||
|
### Linter complains about metainfo
|
||||||
|
- Ensure `su.reya.signed.metainfo.xml` has at least one `<release>` entry
|
||||||
|
- Add accessible screenshot URLs if required
|
||||||
|
|
||||||
|
### Missing dependencies
|
||||||
|
- If new git dependencies are added, re-run `script/prepare-flathub`
|
||||||
|
- The script vendors all dependencies from `Cargo.lock`
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Flathub Submission Docs](https://docs.flathub.org/docs/for-app-authors/submission)
|
||||||
|
- [Flatpak Manifest Reference](https://docs.flatpak.org/en/latest/manifests.html)
|
||||||
|
- [AppStream Metainfo Guide](https://www.freedesktop.org/software/appstream/docs/chap-Metadata.html)
|
||||||
Executable
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
# Function for displaying help info
|
||||||
|
help_info() {
|
||||||
|
echo "
|
||||||
|
Usage: ${0##*/}
|
||||||
|
Build a release .tar.gz for Linux.
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse all arguments manually
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-h|--help)
|
||||||
|
help_info
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
help_info
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unexpected argument: $1" >&2
|
||||||
|
help_info
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||||
|
|
||||||
|
version="$(script/get-crate-version signed)"
|
||||||
|
# Set RELEASE_VERSION so it's compiled into the app and it knows about the version.
|
||||||
|
export RELEASE_VERSION="${version}"
|
||||||
|
|
||||||
|
commit=$(git rev-parse HEAD | cut -c 1-7)
|
||||||
|
|
||||||
|
version_info=$(rustc --version --verbose)
|
||||||
|
host_line=$(echo "$version_info" | grep host)
|
||||||
|
target_triple=${host_line#*: }
|
||||||
|
|
||||||
|
export CC=$(which clang)
|
||||||
|
|
||||||
|
# Build binary in release mode
|
||||||
|
export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib"
|
||||||
|
cargo build --release --target "${target_triple}" --package signed
|
||||||
|
|
||||||
|
# Strip debug symbols and save them
|
||||||
|
objcopy --only-keep-debug "${target_dir}/${target_triple}/release/signed" "${target_dir}/${target_triple}/release/signed.dbg"
|
||||||
|
objcopy --strip-debug "${target_dir}/${target_triple}/release/signed"
|
||||||
|
|
||||||
|
gzip -f "${target_dir}/${target_triple}/release/signed.dbg"
|
||||||
|
|
||||||
|
# Move everything that should end up in the final package
|
||||||
|
# into a temp directory.
|
||||||
|
temp_dir=$(mktemp -d)
|
||||||
|
signed_dir="${temp_dir}/signed.app"
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
mkdir -p "${signed_dir}/bin" "${signed_dir}/libexec"
|
||||||
|
cp "${target_dir}/${target_triple}/release/signed" "${signed_dir}/libexec/signed"
|
||||||
|
cp "${target_dir}/${target_triple}/release/signed" "${signed_dir}/bin/signed"
|
||||||
|
|
||||||
|
# Libs
|
||||||
|
find_libs() {
|
||||||
|
ldd ${target_dir}/${target_triple}/release/signed |\
|
||||||
|
cut -d' ' -f3 |\
|
||||||
|
grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)'
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "${signed_dir}/lib"
|
||||||
|
rm -rf "${signed_dir}/lib/*"
|
||||||
|
cp $(find_libs) "${signed_dir}/lib"
|
||||||
|
|
||||||
|
# Icons
|
||||||
|
mkdir -p "${signed_dir}/share/icons/hicolor/512x512/apps"
|
||||||
|
cp "desktop/resources/icon.png" "${signed_dir}/share/icons/hicolor/512x512/apps/signed.png"
|
||||||
|
mkdir -p "${signed_dir}/share/icons/hicolor/1024x1024/apps"
|
||||||
|
cp "desktop/resources/icon@2x.png" "${signed_dir}/share/icons/hicolor/1024x1024/apps/signed.png"
|
||||||
|
|
||||||
|
# .desktop
|
||||||
|
export DO_STARTUP_NOTIFY="true"
|
||||||
|
export APP_CLI="signed"
|
||||||
|
export APP_ICON="signed"
|
||||||
|
export APP_ARGS="%U"
|
||||||
|
export APP_NAME="Signed"
|
||||||
|
|
||||||
|
mkdir -p "${signed_dir}/share/applications"
|
||||||
|
envsubst < "desktop/resources/signed.desktop.in" > "${signed_dir}/share/applications/signed.desktop"
|
||||||
|
|
||||||
|
# Create archive out of everything that's in the temp directory
|
||||||
|
arch=$(uname -m)
|
||||||
|
target="linux-${arch}"
|
||||||
|
archive="signed-${target}.tar.gz"
|
||||||
|
|
||||||
|
rm -rf "${archive}"
|
||||||
|
remove_match="signed(-[a-zA-Z0-9]+)?-linux-$(uname -m)\.tar\.gz"
|
||||||
|
ls "${target_dir}/release" | grep -E ${remove_match} | xargs -d "\n" -I {} rm -f "${target_dir}/release/{}" || true
|
||||||
|
tar -czvf "${target_dir}/release/$archive" -C ${temp_dir} "signed.app"
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Build the macOS .app/.dmg bundle with cargo-packager.
|
||||||
|
# Requires macOS. Linux/macOS users building on Windows are unsupported here.
|
||||||
|
|
||||||
|
set -euxo pipefail
|
||||||
|
cd "$(dirname "$0")/../desktop"
|
||||||
|
|
||||||
|
if ! command -v cargo-packager >/dev/null 2>&1; then
|
||||||
|
cargo install cargo-packager --locked
|
||||||
|
fi
|
||||||
|
|
||||||
|
cargo packager --release
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <release_version>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
# Snap uses its own architecture names, which must match the file name and
|
||||||
|
# the architecture asserted inside the .snap. `x86_64`/`aarch64` here would
|
||||||
|
# produce a file like signed_1.0.0_x86_64.snap that snapd cannot match.
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64) ARCH_SUFFIX="amd64" ;;
|
||||||
|
aarch64|arm64) ARCH_SUFFIX="arm64" ;;
|
||||||
|
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Setup GUI files
|
||||||
|
mkdir -p snap/gui
|
||||||
|
export DO_STARTUP_NOTIFY="true"
|
||||||
|
export APP_NAME="Signed"
|
||||||
|
export APP_ICON="\${SNAP}/meta/gui/signed.png"
|
||||||
|
export APP_ARGS="%U"
|
||||||
|
envsubst < "desktop/resources/signed.desktop.in" > "snap/gui/signed.desktop"
|
||||||
|
cp "desktop/resources/icon.png" "snap/gui/signed.png"
|
||||||
|
|
||||||
|
# Generate snapcraft.yaml with version and architecture
|
||||||
|
RELEASE_VERSION="$1" ARCH_SUFFIX="$ARCH_SUFFIX" envsubst < desktop/resources/snap/snapcraft.yaml.in > snap/snapcraft.yaml
|
||||||
|
|
||||||
|
# Clean previous builds
|
||||||
|
snapcraft clean
|
||||||
|
|
||||||
|
# Build snap with architecture in filename
|
||||||
|
SNAP_NAME="signed_${1}_${ARCH_SUFFIX}.snap"
|
||||||
|
snapcraft --destructive-mode --output "$SNAP_NAME"
|
||||||
|
|
||||||
|
echo "Created snap package: $SNAP_NAME"
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
Install locally (local builds are unsigned, so snapd requires --dangerous; a
|
||||||
|
plain `snap install ./file.snap` fails with "cannot find signatures with
|
||||||
|
metadata for snap/component ..."):
|
||||||
|
|
||||||
|
sudo snap install --dangerous ./signed_<version>_amd64.snap
|
||||||
|
|
||||||
|
Distribute publicly via the Snap Store (signs the snap, users can then run
|
||||||
|
plain `snap install signed`):
|
||||||
|
|
||||||
|
snapcraft login
|
||||||
|
snapcraft upload ./signed_<version>_amd64.snap
|
||||||
|
snapcraft release signed <uploaded-revision> stable
|
||||||
|
EOF
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Build the Windows .msi/.exe bundles with cargo-packager.
|
||||||
|
# Requires Windows (MSVC toolchain) - run from Git Bash or MSYS2.
|
||||||
|
|
||||||
|
set -euxo pipefail
|
||||||
|
cd "$(dirname "$0")/../desktop"
|
||||||
|
|
||||||
|
if ! command -v cargo-packager >/dev/null 2>&1; then
|
||||||
|
cargo install cargo-packager --locked
|
||||||
|
fi
|
||||||
|
|
||||||
|
cargo packager --release
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
shopt -s extglob
|
||||||
|
|
||||||
|
# Get system architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) ARCH_SUFFIX="x86_64" ;;
|
||||||
|
aarch64) ARCH_SUFFIX="aarch64" ;;
|
||||||
|
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
archive_match="signed(-[a-zA-Z0-9]+)?-linux-${ARCH_SUFFIX}\.tar\.gz"
|
||||||
|
archive=$(ls "target/release" | grep -E ${archive_match})
|
||||||
|
|
||||||
|
export ARCHIVE="$archive"
|
||||||
|
export APP_ID="su.reya.signed"
|
||||||
|
export APP_NAME="Signed"
|
||||||
|
export BRANDING_LIGHT="#C6FF4D"
|
||||||
|
export BRANDING_DARK="#C6FF4D"
|
||||||
|
export ICON_FILE="icon"
|
||||||
|
export CHANNEL="stable"
|
||||||
|
|
||||||
|
# Generate manifest
|
||||||
|
envsubst < "desktop/resources/flatpak/manifest-template.json" > "$APP_ID.json"
|
||||||
|
|
||||||
|
# Build Flatpak
|
||||||
|
flatpak-builder --user --install --force-clean build "$APP_ID.json"
|
||||||
|
|
||||||
|
# Create bundle with architecture suffix
|
||||||
|
OUTPUT_FILE="target/release/${APP_ID}_${ARCH_SUFFIX}.flatpak"
|
||||||
|
flatpak build-bundle ~/.local/share/flatpak/repo "$OUTPUT_FILE" "$APP_ID"
|
||||||
|
echo "Created '$OUTPUT_FILE'"
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
|
|
||||||
|
arch=$(arch)
|
||||||
|
fd_version=24.08
|
||||||
|
flatpak install -y --user org.freedesktop.Platform/${arch}/${fd_version}
|
||||||
|
flatpak install -y --user org.freedesktop.Sdk/${arch}/${fd_version}
|
||||||
|
flatpak install -y --user org.freedesktop.Sdk.Extension.rust-stable/${arch}/${fd_version}
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
echo "Usage: $0 <crate_name>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CRATE_NAME=$1
|
||||||
|
|
||||||
|
cargo metadata \
|
||||||
|
--no-deps \
|
||||||
|
--format-version=1 \
|
||||||
|
| jq \
|
||||||
|
--raw-output \
|
||||||
|
".packages[] | select(.name == \"${CRATE_NAME}\") | .version"
|
||||||
Executable
+238
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -xeuo pipefail
|
||||||
|
|
||||||
|
# if root or if sudo/unavailable, define an empty variable
|
||||||
|
if [ "$(id -u)" -eq 0 ]
|
||||||
|
then maysudo=''
|
||||||
|
else maysudo="$(command -v sudo || command -v doas || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
function finalize {
|
||||||
|
# after packages install (curl, etc), get the rust toolchain
|
||||||
|
which rustup > /dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
# verify the mold situation
|
||||||
|
if ! command -v mold >/dev/null 2>&1; then
|
||||||
|
echo "Warning: Mold binaries are unavailable on your system." >&2
|
||||||
|
echo " Builds will be slower without mold. Try: script/install-mold" >&2
|
||||||
|
fi
|
||||||
|
echo "Finished installing Linux dependencies with script/linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ubuntu, Debian, Mint, Kali, Pop!_OS, Raspbian, etc.
|
||||||
|
apt=$(command -v apt-get || true)
|
||||||
|
if [[ -n $apt ]]; then
|
||||||
|
deps=(
|
||||||
|
gcc
|
||||||
|
g++
|
||||||
|
libasound2-dev
|
||||||
|
libfontconfig-dev
|
||||||
|
libwayland-dev
|
||||||
|
libx11-xcb-dev
|
||||||
|
libxkbcommon-x11-dev
|
||||||
|
libssl-dev
|
||||||
|
libzstd-dev
|
||||||
|
libvulkan1
|
||||||
|
libgit2-dev
|
||||||
|
libx11-dev
|
||||||
|
make
|
||||||
|
cmake
|
||||||
|
clang
|
||||||
|
jq
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
gettext-base
|
||||||
|
elfutils
|
||||||
|
musl-tools
|
||||||
|
musl-dev
|
||||||
|
build-essential
|
||||||
|
)
|
||||||
|
if (grep -qP 'PRETTY_NAME="(Linux Mint 22|.+24\.(04|10))' /etc/os-release); then
|
||||||
|
deps+=( mold libstdc++-14-dev )
|
||||||
|
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+12|Linux Mint 21|.+22\.04)' /etc/os-release); then
|
||||||
|
deps+=( mold libstdc++-12-dev )
|
||||||
|
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+11|Linux Mint 20|.+20\.04)' /etc/os-release); then
|
||||||
|
deps+=( libstdc++-10-dev )
|
||||||
|
fi
|
||||||
|
|
||||||
|
$maysudo "$apt" update
|
||||||
|
$maysudo "$apt" install -y "${deps[@]}"
|
||||||
|
finalize
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fedora, CentOS, RHEL, Alma, Amazon 2023, Oracle, etc.
|
||||||
|
dnf=$(command -v dnf || true)
|
||||||
|
# Old Redhat (yum only): Amazon Linux 2, Oracle Linux 7, etc.
|
||||||
|
yum=$(command -v yum || true)
|
||||||
|
|
||||||
|
if [[ -n $dnf ]] || [[ -n $yum ]]; then
|
||||||
|
pkg_cmd="${dnf:-${yum}}"
|
||||||
|
deps=(
|
||||||
|
musl-gcc
|
||||||
|
gcc
|
||||||
|
clang
|
||||||
|
cmake
|
||||||
|
alsa-lib-devel
|
||||||
|
fontconfig-devel
|
||||||
|
wayland-devel
|
||||||
|
libxcb-devel
|
||||||
|
libxkbcommon-x11-devel
|
||||||
|
openssl-devel
|
||||||
|
libzstd-devel
|
||||||
|
vulkan-loader
|
||||||
|
jq
|
||||||
|
git
|
||||||
|
tar
|
||||||
|
)
|
||||||
|
# perl used for building openssl-sys crate. See: https://docs.rs/openssl/latest/openssl/
|
||||||
|
if grep -qP '^ID="?(fedora)' /etc/os-release; then
|
||||||
|
deps+=(
|
||||||
|
perl-FindBin
|
||||||
|
perl-IPC-Cmd
|
||||||
|
perl-File-Compare
|
||||||
|
perl-File-Copy
|
||||||
|
mold
|
||||||
|
)
|
||||||
|
elif grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release; then
|
||||||
|
deps+=( perl-interpreter )
|
||||||
|
fi
|
||||||
|
|
||||||
|
# gcc-c++ is g++ on RHEL8 and 8.x clones
|
||||||
|
if grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release \
|
||||||
|
&& grep -qP '^VERSION_ID="?(8)' /etc/os-release; then
|
||||||
|
deps+=( gcc-c++ )
|
||||||
|
else
|
||||||
|
deps+=( g++ )
|
||||||
|
fi
|
||||||
|
|
||||||
|
# libxkbcommon-x11-devel is in a non-default repo on RHEL 8.x/9.x (except on AmazonLinux)
|
||||||
|
if grep -qP '^VERSION_ID="?(8|9)' /etc/os-release && grep -qP '^ID="?(rhel|rocky|centos|alma|ol)' /etc/os-release; then
|
||||||
|
$maysudo dnf install -y 'dnf-command(config-manager)'
|
||||||
|
if grep -qP '^PRETTY_NAME="(AlmaLinux 8|Rocky Linux 8)' /etc/os-release; then
|
||||||
|
$maysudo dnf config-manager --set-enabled powertools
|
||||||
|
elif grep -qP '^PRETTY_NAME="((AlmaLinux|Rocky|CentOS Stream) 9|Red Hat.+(8|9))' /etc/os-release; then
|
||||||
|
$maysudo dnf config-manager --set-enabled crb
|
||||||
|
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 8' /etc/os-release; then
|
||||||
|
$maysudo dnf config-manager --set-enabled ol8_codeready_builder
|
||||||
|
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 9' /etc/os-release; then
|
||||||
|
$maysudo dnf config-manager --set-enabled ol9_codeready_builder
|
||||||
|
else
|
||||||
|
echo "Unexpected distro" && grep 'PRETTY_NAME' /etc/os-release && exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
$maysudo "$pkg_cmd" install -y "${deps[@]}"
|
||||||
|
finalize
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# openSUSE
|
||||||
|
# https://software.opensuse.org/
|
||||||
|
zyp=$(command -v zypper || true)
|
||||||
|
if [[ -n $zyp ]]; then
|
||||||
|
deps=(
|
||||||
|
alsa-devel
|
||||||
|
clang
|
||||||
|
cmake
|
||||||
|
fontconfig-devel
|
||||||
|
gcc
|
||||||
|
gcc-c++
|
||||||
|
git
|
||||||
|
gzip
|
||||||
|
jq
|
||||||
|
libvulkan1
|
||||||
|
libx11-devel
|
||||||
|
libxcb-devel
|
||||||
|
libxkbcommon-devel
|
||||||
|
libxkbcommon-x11-devel
|
||||||
|
libzstd-devel
|
||||||
|
make
|
||||||
|
mold
|
||||||
|
openssl-devel
|
||||||
|
tar
|
||||||
|
wayland-devel
|
||||||
|
xcb-util-devel
|
||||||
|
)
|
||||||
|
$maysudo "$zyp" install -y "${deps[@]}"
|
||||||
|
finalize
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Arch, Manjaro, etc.
|
||||||
|
# https://archlinux.org/packages
|
||||||
|
pacman=$(command -v pacman || true)
|
||||||
|
if [[ -n $pacman ]]; then
|
||||||
|
deps=(
|
||||||
|
gcc
|
||||||
|
clang
|
||||||
|
musl
|
||||||
|
cmake
|
||||||
|
alsa-lib
|
||||||
|
fontconfig
|
||||||
|
wayland
|
||||||
|
libgit2
|
||||||
|
libxcb
|
||||||
|
libxkbcommon-x11
|
||||||
|
openssl
|
||||||
|
zstd
|
||||||
|
pkgconf
|
||||||
|
mold
|
||||||
|
jq
|
||||||
|
git
|
||||||
|
)
|
||||||
|
$maysudo "$pacman" -Syu --needed --noconfirm "${deps[@]}"
|
||||||
|
finalize
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Void
|
||||||
|
# https://voidlinux.org/packages/
|
||||||
|
xbps=$(command -v xbps-install || true)
|
||||||
|
if [[ -n $xbps ]]; then
|
||||||
|
deps=(
|
||||||
|
gettext-devel
|
||||||
|
clang
|
||||||
|
cmake
|
||||||
|
jq
|
||||||
|
elfutils-devel
|
||||||
|
gcc
|
||||||
|
alsa-lib-devel
|
||||||
|
fontconfig-devel
|
||||||
|
libxcb-devel
|
||||||
|
libxkbcommon-devel
|
||||||
|
libzstd-devel
|
||||||
|
openssl-devel
|
||||||
|
wayland-devel
|
||||||
|
vulkan-loader
|
||||||
|
mold
|
||||||
|
)
|
||||||
|
$maysudo "$xbps" -Syu "${deps[@]}"
|
||||||
|
finalize
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gentoo
|
||||||
|
# https://packages.gentoo.org/
|
||||||
|
emerge=$(command -v emerge || true)
|
||||||
|
if [[ -n $emerge ]]; then
|
||||||
|
deps=(
|
||||||
|
app-arch/zstd
|
||||||
|
app-misc/jq
|
||||||
|
dev-libs/openssl
|
||||||
|
dev-libs/wayland
|
||||||
|
dev-util/cmake
|
||||||
|
media-libs/alsa-lib
|
||||||
|
media-libs/fontconfig
|
||||||
|
media-libs/vulkan-loader
|
||||||
|
x11-libs/libxcb
|
||||||
|
x11-libs/libxkbcommon
|
||||||
|
sys-devel/mold
|
||||||
|
)
|
||||||
|
$maysudo "$emerge" -u "${deps[@]}"
|
||||||
|
finalize
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Unsupported Linux distribution in script/linux"
|
||||||
|
exit 1
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -xeuo pipefail
|
||||||
|
|
||||||
|
export HOMEBREW_NO_INSTALL_CLEANUP=1
|
||||||
|
|
||||||
|
# if root or if sudo/unavailable, define an empty variable
|
||||||
|
if [ "$(id -u)" -eq 0 ]
|
||||||
|
then maysudo=''
|
||||||
|
else maysudo="$(command -v sudo || command -v doas || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
function finalize {
|
||||||
|
# after packages install (curl, etc), get the rust toolchain
|
||||||
|
which rustup > /dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
# verify the mold situation
|
||||||
|
if ! command -v mold >/dev/null 2>&1; then
|
||||||
|
echo "Warning: Mold binaries are unavailable on your system." >&2
|
||||||
|
echo " Builds will be slower without mold. Try: script/install-mold" >&2
|
||||||
|
fi
|
||||||
|
echo "Finished installing MacOS dependencies with script/macos"
|
||||||
|
}
|
||||||
|
|
||||||
|
# MacOS
|
||||||
|
brew=$(command -v brew || true)
|
||||||
|
if [[ -n $brew ]]; then
|
||||||
|
deps=(
|
||||||
|
gcc
|
||||||
|
libx11
|
||||||
|
libxkbcommon
|
||||||
|
openssl
|
||||||
|
zstd
|
||||||
|
vulkan-headers
|
||||||
|
libgit2
|
||||||
|
libx11
|
||||||
|
make
|
||||||
|
cmake
|
||||||
|
jq
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
gettext
|
||||||
|
)
|
||||||
|
|
||||||
|
$brew update
|
||||||
|
for dep in "${deps[@]}";do
|
||||||
|
$brew search "$dep";
|
||||||
|
done
|
||||||
|
$brew install "${deps[@]}"
|
||||||
|
finalize
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
Executable
+246
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Prepare Flathub submission for Signed
|
||||||
|
# This script:
|
||||||
|
# 1. Vendors all Rust dependencies (crates.io + git)
|
||||||
|
# 2. Generates release info for metainfo.xml
|
||||||
|
# 3. Creates the Flathub manifest (su.reya.signed.yml)
|
||||||
|
#
|
||||||
|
# Usage: ./script/prepare-flathub [--release-date YYYY-MM-DD]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
APP_ID="su.reya.signed"
|
||||||
|
APP_NAME="Signed"
|
||||||
|
REPO_URL="https://git.reya.su/reya/signed"
|
||||||
|
BRANDING_LIGHT="#C6FF4D"
|
||||||
|
BRANDING_DARK="#C6FF4D"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
RELEASE_DATE=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--release-date)
|
||||||
|
RELEASE_DATE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: ${0##*/} [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --release-date DATE Release date in YYYY-MM-DD format (default: today)"
|
||||||
|
echo " -h, --help Display this help and exit"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get version from workspace
|
||||||
|
VERSION=$(script/get-crate-version signed)
|
||||||
|
if [[ -z "$RELEASE_DATE" ]]; then
|
||||||
|
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Preparing Flathub submission for $APP_NAME v$VERSION ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create flathub directory
|
||||||
|
mkdir -p flathub
|
||||||
|
echo "[1/5] Created flathub/ directory"
|
||||||
|
|
||||||
|
# Step 2: Vendor all dependencies
|
||||||
|
echo "[2/5] Vendoring Rust dependencies..."
|
||||||
|
if [[ -d vendor ]]; then
|
||||||
|
echo " Removing old vendor directory..."
|
||||||
|
rm -rf vendor
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create cargo config for vendoring
|
||||||
|
mkdir -p .cargo
|
||||||
|
cat > .cargo/config.toml << 'EOF'
|
||||||
|
[source.crates-io]
|
||||||
|
replace-with = "vendored"
|
||||||
|
|
||||||
|
[source.vendored]
|
||||||
|
directory = "vendor"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Vendor all dependencies (crates.io + git)
|
||||||
|
cargo vendor --locked vendor/
|
||||||
|
echo " Vendored dependencies to vendor/"
|
||||||
|
|
||||||
|
# Create tarball of vendored deps
|
||||||
|
tar -czf flathub/vendor.tar.gz vendor/
|
||||||
|
echo " Created flathub/vendor.tar.gz"
|
||||||
|
|
||||||
|
# Step 3: Generate release info for metainfo
|
||||||
|
echo "[3/5] Generating release info..."
|
||||||
|
cat > flathub/release-info.xml << EOF
|
||||||
|
<release version="${VERSION}" date="${RELEASE_DATE}">
|
||||||
|
<description>
|
||||||
|
<p>Release version ${VERSION}</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
EOF
|
||||||
|
echo " Created flathub/release-info.xml"
|
||||||
|
|
||||||
|
# Step 4: Generate the metainfo file with release info
|
||||||
|
echo "[4/5] Generating metainfo.xml..."
|
||||||
|
export APP_ID APP_NAME BRANDING_LIGHT BRANDING_DARK
|
||||||
|
cat desktop/resources/flatpak/signed.metainfo.xml.in | \
|
||||||
|
sed -e "/@release_info@/r flathub/release-info.xml" -e '/@release_info@/d' \
|
||||||
|
> flathub/${APP_ID}.metainfo.xml
|
||||||
|
echo " Created flathub/${APP_ID}.metainfo.xml"
|
||||||
|
|
||||||
|
# Step 5: Generate the Flatpak manifest
|
||||||
|
echo "[5/5] Generating Flatpak manifest..."
|
||||||
|
|
||||||
|
# Get current commit hash
|
||||||
|
COMMIT=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Generate the YAML manifest
|
||||||
|
cat > flathub/${APP_ID}.yml << 'MANIFEST_EOF'
|
||||||
|
id: su.reya.signed
|
||||||
|
runtime: org.freedesktop.Platform
|
||||||
|
runtime-version: "24.08"
|
||||||
|
sdk: org.freedesktop.Sdk
|
||||||
|
sdk-extensions:
|
||||||
|
- org.freedesktop.Sdk.Extension.rust-stable
|
||||||
|
- org.freedesktop.Sdk.Extension.llvm18
|
||||||
|
command: signed
|
||||||
|
finish-args:
|
||||||
|
- --talk-name=org.freedesktop.Flatpak
|
||||||
|
- --device=dri
|
||||||
|
- --share=ipc
|
||||||
|
- --share=network
|
||||||
|
- --socket=wayland
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --socket=pulseaudio
|
||||||
|
- --filesystem=host
|
||||||
|
|
||||||
|
build-options:
|
||||||
|
append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm18/bin
|
||||||
|
env:
|
||||||
|
CC: clang
|
||||||
|
CXX: clang++
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- name: signed
|
||||||
|
buildsystem: simple
|
||||||
|
build-options:
|
||||||
|
env:
|
||||||
|
CARGO_HOME: /run/build/signed/cargo
|
||||||
|
CARGO_NET_OFFLINE: "true"
|
||||||
|
RELEASE_VERSION: "@VERSION@"
|
||||||
|
build-commands:
|
||||||
|
# Setup vendored dependencies
|
||||||
|
- mkdir -p .cargo
|
||||||
|
- cp cargo-config.toml .cargo/config.toml
|
||||||
|
|
||||||
|
# Extract vendored deps
|
||||||
|
- tar -xzf vendor.tar.gz
|
||||||
|
|
||||||
|
# Build the project (entire workspace, then install signed binary)
|
||||||
|
- cargo build --release --offline --package signed
|
||||||
|
|
||||||
|
# Install binary
|
||||||
|
- install -Dm755 target/release/signed /app/bin/signed
|
||||||
|
|
||||||
|
# Install icons
|
||||||
|
- install -Dm644 desktop/resources/icon.png /app/share/icons/hicolor/512x512/apps/su.reya.signed.png
|
||||||
|
- install -Dm644 desktop/resources/icon@2x.png /app/share/icons/hicolor/1024x1024/apps/su.reya.signed.png
|
||||||
|
|
||||||
|
# Install desktop file
|
||||||
|
- |
|
||||||
|
export APP_ID="su.reya.signed"
|
||||||
|
export APP_ICON="su.reya.signed"
|
||||||
|
export APP_NAME="Signed"
|
||||||
|
export APP_CLI="signed"
|
||||||
|
export APP_ARGS="%U"
|
||||||
|
export DO_STARTUP_NOTIFY="true"
|
||||||
|
envsubst < desktop/resources/signed.desktop.in > signed.desktop
|
||||||
|
install -Dm644 signed.desktop /app/share/applications/su.reya.signed.desktop
|
||||||
|
|
||||||
|
# Install metainfo (use pre-generated one with release info)
|
||||||
|
- install -Dm644 su.reya.signed.metainfo.xml /app/share/metainfo/su.reya.signed.metainfo.xml
|
||||||
|
|
||||||
|
sources:
|
||||||
|
# Main source code - specific commit
|
||||||
|
- type: git
|
||||||
|
url: https://git.reya.su/reya/signed.git
|
||||||
|
commit: "@COMMIT@"
|
||||||
|
tag: "v@VERSION@"
|
||||||
|
|
||||||
|
# Vendored dependencies tarball (generated by this script)
|
||||||
|
- type: file
|
||||||
|
path: vendor.tar.gz
|
||||||
|
sha256: "@VENDOR_SHA256@"
|
||||||
|
|
||||||
|
# Pre-generated metainfo with release info
|
||||||
|
- type: file
|
||||||
|
path: su.reya.signed.metainfo.xml
|
||||||
|
sha256: "@METAINFO_SHA256@"
|
||||||
|
|
||||||
|
# Cargo config for vendoring
|
||||||
|
- type: file
|
||||||
|
path: cargo-config.toml
|
||||||
|
sha256: "@CARGO_CONFIG_SHA256@"
|
||||||
|
MANIFEST_EOF
|
||||||
|
|
||||||
|
# Calculate SHA256 hashes
|
||||||
|
VENDOR_SHA256=$(sha256sum flathub/vendor.tar.gz | cut -d' ' -f1)
|
||||||
|
METAINFO_SHA256=$(sha256sum flathub/${APP_ID}.metainfo.xml | cut -d' ' -f1)
|
||||||
|
|
||||||
|
# Create cargo-config.toml
|
||||||
|
mkdir -p flathub
|
||||||
|
cat > flathub/cargo-config.toml << 'EOF'
|
||||||
|
[source.crates-io]
|
||||||
|
replace-with = "vendored"
|
||||||
|
|
||||||
|
[source.vendored]
|
||||||
|
directory = "vendor"
|
||||||
|
EOF
|
||||||
|
CARGO_CONFIG_SHA256=$(sha256sum flathub/cargo-config.toml | cut -d' ' -f1)
|
||||||
|
|
||||||
|
# Substitute values into the manifest
|
||||||
|
sed -i.bak \
|
||||||
|
-e "s/@VERSION@/${VERSION}/g" \
|
||||||
|
-e "s/@COMMIT@/${COMMIT}/g" \
|
||||||
|
-e "s/@VENDOR_SHA256@/${VENDOR_SHA256}/g" \
|
||||||
|
-e "s/@METAINFO_SHA256@/${METAINFO_SHA256}/g" \
|
||||||
|
-e "s/@CARGO_CONFIG_SHA256@/${CARGO_CONFIG_SHA256}/g" \
|
||||||
|
flathub/${APP_ID}.yml
|
||||||
|
rm -f flathub/${APP_ID}.yml.bak
|
||||||
|
|
||||||
|
echo " Created flathub/${APP_ID}.yml"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Flathub preparation complete! ==="
|
||||||
|
echo ""
|
||||||
|
echo "Files generated in flathub/:"
|
||||||
|
echo " - ${APP_ID}.yml # Main Flatpak manifest (submit this to Flathub)"
|
||||||
|
echo " - ${APP_ID}.metainfo.xml # AppStream metadata with release info"
|
||||||
|
echo " - vendor.tar.gz # Vendored Rust dependencies"
|
||||||
|
echo " - cargo-config.toml # Cargo configuration for vendoring"
|
||||||
|
echo " - release-info.xml # Release info snippet"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Test the build locally:"
|
||||||
|
echo " cd flathub && flatpak-builder --user --install --force-clean build ${APP_ID}.yml"
|
||||||
|
echo ""
|
||||||
|
echo " 2. If build succeeds, submit to Flathub:"
|
||||||
|
echo " - Fork https://github.com/flathub/flathub"
|
||||||
|
echo " - Clone: git clone --branch=new-pr git@github.com:YOUR_USERNAME/flathub.git"
|
||||||
|
echo " - Copy ONLY ${APP_ID}.yml to the repo"
|
||||||
|
echo " - Submit PR against flathub/flathub:new-pr"
|
||||||
|
echo ""
|
||||||
|
echo "Note: Make sure you have:"
|
||||||
|
echo " - Committed all changes (commit: ${COMMIT})"
|
||||||
|
echo " - Tagged the release (tag: v${VERSION})"
|
||||||
|
echo " - Pushed the tag"
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to release a new version of the application
|
||||||
|
# Usage: ./release <new_version>
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <new_version>"
|
||||||
|
echo "Example: $0 1.0.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_VERSION="$1"
|
||||||
|
WORKSPACE_CARGO="Cargo.toml"
|
||||||
|
CRATE_CARGO="desktop/Cargo.toml"
|
||||||
|
|
||||||
|
# Check if both Cargo.toml files exist
|
||||||
|
if [ ! -f "$WORKSPACE_CARGO" ]; then
|
||||||
|
echo "Error: $WORKSPACE_CARGO not found in current directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CRATE_CARGO" ]; then
|
||||||
|
echo "Error: $CRATE_CARGO not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to update version in a Cargo.toml file
|
||||||
|
update_version() {
|
||||||
|
local file="$1"
|
||||||
|
local backup="${file}.bak"
|
||||||
|
|
||||||
|
# Backup the original file
|
||||||
|
cp "$file" "$backup"
|
||||||
|
|
||||||
|
# More flexible regex that handles various version formats and whitespace.
|
||||||
|
# Note: -i.bak works on both GNU and BSD sed.
|
||||||
|
if sed -i.bak -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
|
||||||
|
echo "✓ Updated version to $NEW_VERSION in $file"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to update version in $file"
|
||||||
|
# Restore original backup
|
||||||
|
mv "$backup" "$file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove the backup file
|
||||||
|
rm -f "$backup"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update both Cargo.toml files
|
||||||
|
echo "Updating versions..."
|
||||||
|
update_version "$WORKSPACE_CARGO"
|
||||||
|
update_version "$CRATE_CARGO"
|
||||||
|
|
||||||
|
# Check git status before committing
|
||||||
|
echo "Checking git status..."
|
||||||
|
if git status --porcelain | grep -q .; then
|
||||||
|
echo "Current uncommitted changes:"
|
||||||
|
git status --short
|
||||||
|
|
||||||
|
# Ask user if they want to commit all changes or just version files
|
||||||
|
echo ""
|
||||||
|
echo "Do you want to:"
|
||||||
|
echo "1) Commit all current changes (including the version updates)"
|
||||||
|
echo "2) Commit only the version file changes"
|
||||||
|
echo "3) Abort the release"
|
||||||
|
read -p "Enter choice (1/2/3): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo "Committing all changes..."
|
||||||
|
git add .
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "Committing only version file changes..."
|
||||||
|
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "Release aborted by user"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Release aborted."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
# Only version files were modified, add them specifically
|
||||||
|
echo "Only version files were modified, adding them for commit..."
|
||||||
|
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Commit the changes
|
||||||
|
COMMIT_MSG="chore: release version $NEW_VERSION"
|
||||||
|
|
||||||
|
if git commit -m "$COMMIT_MSG"; then
|
||||||
|
echo "✓ Committed version changes"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to commit version changes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Push version changes to origin
|
||||||
|
echo "Pushing version changes to origin..."
|
||||||
|
if git push origin master; then
|
||||||
|
echo "✓ Successfully pushed version changes to origin"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to push version changes to origin"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create git tag
|
||||||
|
TAG_NAME="v$NEW_VERSION"
|
||||||
|
|
||||||
|
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
||||||
|
echo "✓ Created git tag: $TAG_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to create git tag"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Push tag to origin
|
||||||
|
echo "Pushing tag to origin..."
|
||||||
|
if git push origin "$TAG_NAME"; then
|
||||||
|
echo "✓ Successfully pushed tag to origin"
|
||||||
|
echo "✓ Release $NEW_VERSION completed successfully!"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to push tag to origin"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# This script is intended to be run after `script/bundle-snap`.
|
||||||
|
#
|
||||||
|
# It expects a version to be passed as the first argument, and expects the
|
||||||
|
# built `.snap` for that version to be in the current directory. bundle-snap
|
||||||
|
# names it `signed_<version>_amd64.snap` (Snap's canonical amd64/arm64 naming).
|
||||||
|
#
|
||||||
|
# This will uninstall the current `signed` snap, replacing it with a snap
|
||||||
|
# that directly uses the `snap/unpacked` directory.
|
||||||
|
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <release_version>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rerun as root
|
||||||
|
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
||||||
|
|
||||||
|
snap remove signed || true
|
||||||
|
mkdir -p snap
|
||||||
|
rm -rf snap/unpacked
|
||||||
|
unsquashfs -dest snap/unpacked "signed_$1_amd64.snap"
|
||||||
|
snap try --classic snap/unpacked
|
||||||
Reference in New Issue
Block a user