chore: migrate from git command to gix #16
Generated
+52
@@ -2524,6 +2524,7 @@ dependencies = [
|
||||
"gix-credentials",
|
||||
"gix-date",
|
||||
"gix-diff",
|
||||
"gix-dir",
|
||||
"gix-discover",
|
||||
"gix-error",
|
||||
"gix-features",
|
||||
@@ -2551,6 +2552,7 @@ dependencies = [
|
||||
"gix-revwalk",
|
||||
"gix-sec",
|
||||
"gix-shallow",
|
||||
"gix-status",
|
||||
"gix-submodule",
|
||||
"gix-tempfile",
|
||||
"gix-trace",
|
||||
@@ -2710,13 +2712,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"gix-attributes",
|
||||
"gix-command",
|
||||
"gix-filter",
|
||||
"gix-fs",
|
||||
"gix-hash",
|
||||
"gix-imara-diff",
|
||||
"gix-index",
|
||||
"gix-object",
|
||||
"gix-path",
|
||||
"gix-pathspec",
|
||||
"gix-tempfile",
|
||||
"gix-trace",
|
||||
"gix-traverse",
|
||||
@@ -2724,6 +2729,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gix-discover"
|
||||
version = "0.55.0"
|
||||
@@ -3182,6 +3207,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gix-submodule"
|
||||
version = "0.34.0"
|
||||
@@ -7909,6 +7959,8 @@ version = "0.1.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gix",
|
||||
"gix-worktree",
|
||||
"gix-worktree-state",
|
||||
"nostr",
|
||||
"signed_core",
|
||||
"tempfile",
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ nostr-gossip-memory = { 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" }
|
||||
|
||||
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"
|
||||
futures = "0.3"
|
||||
|
||||
@@ -9,6 +9,8 @@ signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
gix-worktree = "0.56"
|
||||
gix-worktree-state = "0.34"
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+679
-205
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Error;
|
||||
@@ -617,67 +616,27 @@ fn resolve_associations<'a>(
|
||||
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.
|
||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||
let branches = signed_git::worktree_branches(path).ok()?;
|
||||
if branches.is_empty() || worktree_dirty(path) {
|
||||
|
||||
if branches.is_empty() || signed_git::worktree_dirty(path) {
|
||||
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 base = announced_head
|
||||
.filter(|name| branches.iter().any(|b| b == name))
|
||||
.map(str::to_owned)
|
||||
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
|
||||
.or_else(|| branches.first().cloned())?;
|
||||
|
||||
if base == branch {
|
||||
return None;
|
||||
}
|
||||
let ahead = commits_ahead(path, &base, &branch);
|
||||
|
||||
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
|
||||
(ahead > 0).then_some(CheckoutStatus {
|
||||
path: path.to_path_buf(),
|
||||
branch,
|
||||
@@ -687,30 +646,17 @@ 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.
|
||||
///
|
||||
/// `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 worktree_dirty(path) {
|
||||
if signed_git::worktree_dirty(path) {
|
||||
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 origin = signed_git::origin_url(path).ok().flatten()?;
|
||||
|
||||
@@ -724,15 +670,15 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||
|
||||
// A branch never fetched or pushed yet compares against the remote HEAD.
|
||||
// The remote HEAD is the fork point in practice.
|
||||
let base = if ref_exists(path, &remote) {
|
||||
let base = if signed_git::worktree_ref_exists(path, &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()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let ahead = commits_ahead(path, &base, &branch);
|
||||
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
|
||||
|
||||
(ahead > 0).then_some(CheckoutStatus {
|
||||
path: path.to_path_buf(),
|
||||
@@ -822,6 +768,8 @@ pub fn pr_proposes_checkout(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::process::Command;
|
||||
|
||||
use signed_core::{RepoAddr, repo_addr};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -492,16 +492,8 @@ impl RepoDetailView {
|
||||
if refresh_generation != this.ref_generation {
|
||||
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 tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
|
||||
|
||||
@@ -528,6 +520,10 @@ impl RepoDetailView {
|
||||
this.load_all_commits(cx);
|
||||
}
|
||||
|
||||
if moved {
|
||||
this.catch_up_worktree(cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
@@ -1225,6 +1221,119 @@ impl RepoDetailView {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// 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 = 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(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Keep the currently selected file.
|
||||
/// An evicted file's parsed editor state drops with its entry.
|
||||
|
||||
Reference in New Issue
Block a user