Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ All notable changes to AngKorGit are documented here. The format follows

## [Unreleased]

### Added
- Right-click a group header on the start page to **Fetch all** or **Pull all
(fast-forward only)**. A live summary lists moved refs and commit ranges, unchanged
repositories, skipped repositories with reasons, and failures. Pull follows each
checked-out branch's configured upstream and skips detached HEADs, unfinished
operations, diverged history, and local changes that conflict with safe checkout.
Unrelated untracked files and staged or unstaged edits are preserved. Cancel stops the remaining batch,
prevents later ref/worktree updates, and releases the app even if a network read is
stalled; completed updates are kept. Partial fetches have a single outcome in the
summary, including when a remote succeeds without moving refs. Missing upstream
refs are reported separately from unconfigured upstreams. Ref-update errors
after checkout explicitly explain the working-tree/branch mismatch; abandoned run
entries expire on later registry activity.
Group pull uses the same checkout behavior as toolbar Pull. The summary has flat
rows, toned badges, and a single-line counts strip. Per-remote prune settings
override fetch.prune, including an explicit false. Completed checkouts retain their
result when cancelling; settled summary rows ignore late responses and errors.
URL-based upstreams are supported. Fetch cancellation keeps successful remotes and
errors together, with ref summaries calculated outside the write guard; queued
repositories say they never started, and unchanged fetch badges match the counts.
Pull also handles upstreams outside a remote's fetch refspec. Cancellation preserves
completed results if one repository's ref summary becomes unavailable. (#69)

## [0.23.0] — 2026-10-13

The working-copy release. Folders in the file tree stage and unstage as one, the
Expand Down
28 changes: 22 additions & 6 deletions CLAUDE.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,31 @@ pub async fn remote_fetch(
blocking(move || remote::fetch(&path, &remote, tags, prune)).await
}

#[tauri::command]
pub async fn repository_group_update(
path: String,
pull: bool,
runId: String,
) -> AppResult<crate::core::types::GroupUpdateResult> {
let control = crate::core::group_update::control(&runId)?;
blocking(move || remote::group_update_controlled(&path, pull, &control)).await
}

#[tauri::command]
pub fn repository_group_start(runId: String) -> AppResult<()> {
crate::core::group_update::start(&runId)
}

#[tauri::command]
pub async fn repository_group_cancel(runId: String) -> AppResult<Vec<GroupUpdateReceipt>> {
blocking(move || crate::core::group_update::cancel(&runId)).await
}

#[tauri::command]
pub async fn repository_group_finish(runId: String) -> AppResult<()> {
blocking(move || crate::core::group_update::finish(&runId)).await
}

#[tauri::command]
pub async fn remote_pull(
path: String,
Expand Down
287 changes: 287 additions & 0 deletions apps/desktop/src-tauri/src/core/group_update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};

use super::types::{GroupUpdateReceipt, GroupUpdateResult};
use crate::error::{AppError, AppResult};

#[derive(Default)]
pub struct GroupUpdateControl {
cancelled: Mutex<bool>,
completed: Mutex<HashMap<String, Completed>>,
}

#[derive(Clone)]
enum Completed {
Result(GroupUpdateResult),
Fetch(FetchProgress),
}

#[derive(Clone)]
pub(super) struct FetchProgress {
pub before: Arc<HashMap<String, git2::Oid>>,
pub succeeded: usize,
pub total: usize,
pub failures: Vec<String>,
}

impl GroupUpdateControl {
pub fn cancel(&self) -> AppResult<()> {
*self
.cancelled
.lock()
.map_err(|_| AppError::other("Group update lock poisoned"))? = true;
Ok(())
}

pub fn cancel_results(&self) -> AppResult<Vec<GroupUpdateReceipt>> {
self.cancel()?;
let completed = self
.completed
.lock()
.map_err(|_| AppError::other("Group results lock poisoned"))?
.clone();
Ok(completed
.into_iter()
.map(|(path, completed)| {
let result = match completed {
Completed::Result(result) => result,
Completed::Fetch(progress) => {
match super::repo::open(&path).and_then(|repo| {
super::remote::fetch_progress_result(&repo, &progress, true)
}) {
Ok(result) => result,
Err(error) => {
let mut result = super::remote::fetch_progress_summary(
&progress,
true,
Vec::new(),
);
if progress.succeeded > 0 {
result.status = "partial".into();
}
result
.message
.push_str(&format!(". Could not inspect moved refs: {error}"));
result
}
}
}
};
GroupUpdateReceipt { path, result }
})
.collect())
}

pub(super) fn record_fetch(&self, path: &str, progress: FetchProgress) -> AppResult<()> {
self.completed
.lock()
.map_err(|_| AppError::other("Group results lock poisoned"))?
.insert(path.into(), Completed::Fetch(progress));
Ok(())
}

pub(super) fn fetch_progress(&self, path: &str) -> Option<FetchProgress> {
self.completed
.lock()
.ok()
.and_then(|completed| match completed.get(path) {
Some(Completed::Fetch(progress)) => Some(progress.clone()),
_ => None,
})
}

pub fn with_active_result(
&self,
path: &str,
action: impl FnOnce() -> AppResult<GroupUpdateResult>,
) -> AppResult<GroupUpdateResult> {
self.with_active(|| {
let result = action();
let receipt = match &result {
Ok(result) => result.clone(),
Err(error) => GroupUpdateResult {
status: "failed".into(),
message: error.to_string(),
changes: Vec::new(),
},
};
self.completed
.lock()
.map_err(|_| AppError::other("Group results lock poisoned"))?
.insert(path.into(), Completed::Result(receipt));
result
})
}

pub fn has_completed(&self, path: &str) -> bool {
self.completed
.lock()
.map(|completed| completed.contains_key(path))
.unwrap_or(false)
}

pub fn is_cancelled(&self) -> bool {
self.cancelled
.lock()
.map(|cancelled| *cancelled)
.unwrap_or(true)
}

fn active_guard(&self) -> AppResult<MutexGuard<'_, bool>> {
let cancelled = self
.cancelled
.lock()
.map_err(|_| AppError::other("Group update lock poisoned"))?;
if *cancelled {
return Err(AppError::other("Group update cancelled"));
}
Ok(cancelled)
}

pub fn ensure_active(&self) -> AppResult<()> {
drop(self.active_guard()?);
Ok(())
}

pub fn with_active<T>(&self, action: impl FnOnce() -> AppResult<T>) -> AppResult<T> {
let _guard = self.active_guard()?;
action()
}
}

struct Run {
control: Arc<GroupUpdateControl>,
last_used: Instant,
}

type Runs = HashMap<String, Run>;
static RUNS: OnceLock<Mutex<Runs>> = OnceLock::new();
const RUN_TTL: Duration = Duration::from_secs(60 * 60);

fn runs() -> &'static Mutex<Runs> {
RUNS.get_or_init(|| Mutex::new(HashMap::new()))
}

fn prune_expired(runs: &mut Runs, now: Instant) {
runs.retain(|_, run| {
if now.saturating_duration_since(run.last_used) < RUN_TTL {
return true;
}
match run.control.cancelled.try_lock() {
Ok(mut cancelled) => {
*cancelled = true;
false
}
Err(std::sync::TryLockError::WouldBlock) => true,
Err(std::sync::TryLockError::Poisoned(_)) => false,
}
});
}

pub fn start(id: &str) -> AppResult<()> {
let mut runs = runs()
.lock()
.map_err(|_| AppError::other("Group update registry poisoned"))?;
let now = Instant::now();
prune_expired(&mut runs, now);
if runs.contains_key(id) {
return Err(AppError::other("Group update already exists"));
}
runs.insert(
id.into(),
Run {
control: Arc::new(GroupUpdateControl::default()),
last_used: now,
},
);
Ok(())
}

pub fn control(id: &str) -> AppResult<Arc<GroupUpdateControl>> {
let mut runs = runs()
.lock()
.map_err(|_| AppError::other("Group update registry poisoned"))?;
let now = Instant::now();
let control = runs.get_mut(id).map(|run| {
run.last_used = now;
run.control.clone()
});
prune_expired(&mut runs, now);
control.ok_or_else(|| AppError::other("Group update is no longer active"))
}

pub fn cancel(id: &str) -> AppResult<Vec<GroupUpdateReceipt>> {
let mut runs = runs()
.lock()
.map_err(|_| AppError::other("Group update registry poisoned"))?;
let now = Instant::now();
let control = runs.get_mut(id).map(|run| {
run.last_used = now;
run.control.clone()
});
prune_expired(&mut runs, now);
drop(runs);
match control {
Some(control) => control.cancel_results(),
None => Ok(Vec::new()),
}
}

pub fn finish(id: &str) -> AppResult<()> {
let mut runs = runs()
.lock()
.map_err(|_| AppError::other("Group update registry poisoned"))?;
let control = runs.remove(id).map(|run| run.control);
prune_expired(&mut runs, Instant::now());
drop(runs);
if let Some(control) = control {
control.cancel()?;
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn stale_runs_are_removed_and_cancel_their_in_flight_downloads() {
let now = Instant::now();
let control = Arc::new(GroupUpdateControl::default());
let mut runs = HashMap::from([(
"stale".into(),
Run {
control: control.clone(),
last_used: now - RUN_TTL,
},
)]);
prune_expired(&mut runs, now);
assert!(runs.is_empty());
assert!(control.is_cancelled());
assert!(control
.with_active::<()>(|| panic!("expired run cannot mutate"))
.is_err());
}

#[test]
fn expiry_never_blocks_or_interrupts_an_active_checkout() {
let now = Instant::now();
let control = Arc::new(GroupUpdateControl::default());
let mut runs = HashMap::from([(
"active".into(),
Run {
control: control.clone(),
last_used: now - RUN_TTL,
},
)]);
let guard = control.cancelled.lock().unwrap();
prune_expired(&mut runs, now);
assert_eq!(runs.len(), 1);
assert!(!*guard);
drop(guard);
prune_expired(&mut runs, now);
assert!(runs.is_empty());
assert!(control.is_cancelled());
}
}
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod commit;
pub mod conflict;
pub mod diff;
pub mod files;
pub mod group_update;
pub mod history;
pub mod misc;
pub mod remote;
Expand Down
Loading
Loading