Skip to main content

gunnar_server/control_plane/
clan_plane.rs

1//! **Clans on the real plane** — [`gunnar_grpc::clans::ClanPlane`] for
2//! [`CoreControlPlane`] (owner, 2026-10-05). The clan rows are
3//! `gunnar_core::AuthTable`'s, in one compare-and-swap with the grants rule 2½
4//! widens; the clan's namespace is the catalog's, claimed FIRST — one claim for
5//! the name, exactly as a principal's account namespace is claimed with it —
6//! and given back if the table refuses the clan.
7//!
8//! **Round 2** (owner, 2026-10-05): a member whose paid period has ended
9//! LAPSES — its share stays on the row and the door stops counting it
10//! ([`CoreControlPlane::clan_lapsed`], the entitlement slot's one reading);
11//! and a repository moves OUT of a clan into an account, offered by the
12//! clan's admin and taken by the account's admin, through the write paths
13//! every push takes ([`CoreControlPlane::move_repository`]) so the twin and
14//! a restore see it as they see everything else.
15
16use std::sync::Arc;
17
18use anyhow::Context as _;
19use gunnar_core::{AccountRoom, ClanRefusal, ClanRole};
20use gunnar_grpc::clans::{ClanPlane, ClanRoom};
21use gunnar_grpc::seam::{ControlError, ControlResult, DeleteOptions};
22use gunnar_grpc::ControlPlane as _;
23use gunnar_lfs::LfsStore as _;
24use gunnar_store::git_storage_trait::{Caps, RefUpdate};
25
26use super::{internal, CoreControlPlane};
27
28/// The table's clan onto the wire: what the plane knows. The service fills
29/// what the caller sees of it. `lapsed`: the members whose shares are out of
30/// force now.
31fn clan_out(clan: &gunnar_core::Clan, lapsed: &[String]) -> gunnar_proto::Clan {
32    gunnar_proto::Clan {
33        name: clan.name.clone(),
34        created_by: clan.created_by.clone(),
35        created_at_unix_ms: clan.created_at,
36        members: clan
37            .members
38            .iter()
39            .map(|m| gunnar_proto::ClanMember {
40                principal_id: m.principal.clone(),
41                admin: m.admin,
42                contribution_bytes: m.contribution_bytes,
43                joined_at_unix_ms: m.joined_at,
44                lapsed: lapsed.contains(&m.principal),
45            })
46            .collect(),
47        invites: clan
48            .invites
49            .iter()
50            .map(|i| gunnar_proto::ClanInvite {
51                principal_id: i.principal.clone(),
52                invited_by: i.by.clone(),
53                at_unix_ms: i.at,
54            })
55            .collect(),
56        room_bytes: clan.room_bytes(),
57        room_in_force_bytes: clan.room_in_force(lapsed),
58        offers: clan
59            .offers
60            .iter()
61            .map(|o| offer_out(&clan.name, o, None))
62            .collect(),
63        ..Default::default()
64    }
65}
66
67/// An offer onto the wire.
68fn offer_out(
69    clan: &str,
70    offer: &gunnar_core::ClanRepoOffer,
71    stored: Option<u64>,
72) -> gunnar_proto::RepoOffer {
73    gunnar_proto::RepoOffer {
74        clan: clan.to_owned(),
75        repo: offer.repo.clone(),
76        to_account: offer.to.clone(),
77        offered_by: offer.by.clone(),
78        at_unix_ms: offer.at,
79        stored_bytes: stored,
80    }
81}
82
83fn room_in(room: ClanRoom) -> AccountRoom {
84    AccountRoom {
85        cap: room.cap,
86        stored: room.stored,
87    }
88}
89
90/// A refusal the table made, as the seam says it.
91fn clan_error(err: gunnar_core::Error) -> ControlError {
92    match err {
93        gunnar_core::Error::Clan {
94            clan,
95            refusal,
96            reason,
97        } => match refusal {
98            ClanRefusal::NotFound => {
99                let _ = reason;
100                ControlError::NotFound(format!("clan {clan} (none you are in or invited to)"))
101            }
102            ClanRefusal::Denied => ControlError::PermissionDenied(reason),
103            ClanRefusal::Precondition => ControlError::FailedPrecondition(reason),
104            ClanRefusal::Invalid => ControlError::InvalidArgument(reason),
105            ClanRefusal::Exists => ControlError::AlreadyExists(reason),
106        },
107        gunnar_core::Error::PrincipalExists(id) => {
108            ControlError::AlreadyExists(format!("an account called {id}"))
109        }
110        other => internal(other),
111    }
112}
113
114impl CoreControlPlane {
115    /// After a clan write: the identity half of the metadata mirror, as
116    /// every other identity write does.
117    fn clan_written(&self, clan: gunnar_core::Clan) -> gunnar_proto::Clan {
118        self.stores.mirror_identity();
119        self.clan_view(&clan)
120    }
121
122    /// The clan onto the wire, its lapsed shares and its offers' sizes read.
123    fn clan_view(&self, clan: &gunnar_core::Clan) -> gunnar_proto::Clan {
124        let mut out = clan_out(clan, &self.clan_lapsed(clan));
125        if !out.offers.is_empty() {
126            for offer in &mut out.offers {
127                offer.stored_bytes = self.repo_stored(&clan.name, &offer.repo);
128            }
129        }
130        out
131    }
132
133    /// **The members whose shares are out of force now** (round 2, owner
134    /// 2026-10-05: "the clan KEEPS that member's room share until the
135    /// member's paid period ends, then the share drops"). The entitlement
136    /// slot reads each member's fact — the one reading the door makes too
137    /// ([`crate::entitlement::EntitlementSlot::lapsed_members`]). A box that
138    /// sells nothing holds no paid period, and no share lapses there.
139    pub(crate) fn clan_lapsed(&self, clan: &gunnar_core::Clan) -> Vec<String> {
140        #[cfg(feature = "metered")]
141        if let Some(slot) = self.entitlement.as_ref() {
142            return slot.lapsed_members(clan);
143        }
144        let _ = clan;
145        Vec::new()
146    }
147
148    /// [`Self::clan_lapsed`] by the clan's name (empty for no clan).
149    fn lapsed_in(&self, clan: &str) -> ControlResult<Vec<String>> {
150        Ok(self
151            .stores
152            .auth()
153            .clan(clan)
154            .map_err(internal)?
155            .map(|row| self.clan_lapsed(&row))
156            .unwrap_or_default())
157    }
158
159    /// What `namespace/repo` stores — packs and LFS objects — or `None`
160    /// when it could not be measured.
161    fn repo_stored(&self, namespace: &str, repo: &str) -> Option<u64> {
162        let rows = self.repo_usage(namespace).ok()?;
163        rows.into_iter()
164            .find(|row| row.repo.as_ref().is_some_and(|p| p.repo == repo))
165            .map(|row| row.stored_bytes.saturating_add(row.lfs_bytes))
166    }
167
168    /// **Move `from` to `to`**, everything it holds, through the write paths
169    /// every push takes: a new repository at `to` made as `from` is (its hash,
170    /// default branch, visibility, description), its objects and references
171    /// copied in as ONE pack and ONE ref batch, HEAD, its LFS objects, its
172    /// branch protections and ref-name policy — then `from` purged. Every step
173    /// is a frame the transaction log already carries, so a twin and a restore
174    /// see a move as a create, a push and a purge; no new kind of frame.
175    ///
176    /// A failure before the purge undoes the new repository and leaves `from`
177    /// exactly as it was. The pack is held in memory once.
178    pub(crate) fn move_repository(
179        &self,
180        from: &gunnar_proto::RepoPath,
181        to: &gunnar_proto::RepoPath,
182        by: &str,
183    ) -> ControlResult<gunnar_proto::Repo> {
184        let src = self.get_repo(from)?;
185        if self
186            .stores
187            .catalog()
188            .namespace(&to.namespace)
189            .map_err(internal)?
190            .is_none()
191        {
192            return Err(ControlError::NotFound(format!("account {}", to.namespace)));
193        }
194        if self.get_repo(to).is_ok() {
195            return Err(ControlError::AlreadyExists(format!(
196                "{}/{} already exists, so {}/{} cannot move there",
197                to.namespace, to.repo, from.namespace, from.repo
198            )));
199        }
200        self.create_repo(gunnar_grpc::NewRepo {
201            path: to.clone(),
202            hash_kind: gunnar_proto::HashKind::try_from(src.hash_kind)
203                .unwrap_or(gunnar_proto::HashKind::Sha256),
204            default_branch: src.default_branch.clone(),
205            visibility: gunnar_proto::Visibility::try_from(src.visibility)
206                .unwrap_or(gunnar_proto::Visibility::Private),
207            description: src.description.clone(),
208            adopt: false,
209        })?;
210        if let Err(err) = self.copy_repository(from, to) {
211            if let Err(undo) = self.delete_repo(
212                to,
213                DeleteOptions {
214                    purge: true,
215                    retention_days: None,
216                    principal_id: by.to_owned(),
217                },
218            ) {
219                tracing::error!(
220                    target: "gunnar::audit",
221                    event = "repo.move_undo_failed",
222                    to = %format!("{}/{}", to.namespace, to.repo),
223                    reason = %undo,
224                    "a failed move's new repository could not be purged again"
225                );
226            }
227            return Err(ControlError::Internal(format!(
228                "moving {}/{} to {}/{}: {err:#}; nothing was moved",
229                from.namespace, from.repo, to.namespace, to.repo
230            )));
231        }
232        // **Encrypted stays encrypted** (wiring tracer B 2026-10-08): set
233        // after the copy (the copy is the members' own history, older
234        // plaintext included, and is not re-judged) and before the purge, so a
235        // move never leaves a repository that takes plaintext where the
236        // members were promised it would not.
237        if src.encrypted {
238            if let Err(err) = self.set_encrypted(to, true) {
239                let _ = self.delete_repo(
240                    to,
241                    DeleteOptions {
242                        purge: true,
243                        retention_days: None,
244                        principal_id: by.to_owned(),
245                    },
246                );
247                return Err(ControlError::Internal(format!(
248                    "moving {}/{} to {}/{}: its encryption could not be carried ({err}); nothing was moved",
249                    from.namespace, from.repo, to.namespace, to.repo
250                )));
251            }
252        }
253        // The policy goes with it: a protected branch stays protected.
254        for rule in self.get_protection(from, None).unwrap_or_default() {
255            if let Err(err) = self.set_protection(to, rule) {
256                tracing::warn!(target: "gunnar::audit", event = "repo.move_policy_not_carried", reason = %err, "a branch protection did not move with its repository");
257            }
258        }
259        if let Ok(policy) = self.get_ref_name_policy(from) {
260            if policy != gunnar_proto::RefNamePolicy::default() {
261                if let Err(err) = self.set_ref_name_policy(to, policy) {
262                    tracing::warn!(target: "gunnar::audit", event = "repo.move_policy_not_carried", reason = %err, "a ref-name policy did not move with its repository");
263                }
264            }
265        }
266        // …and the old one goes for good: its bytes leave the clan's room.
267        self.delete_repo(
268            from,
269            DeleteOptions {
270                purge: true,
271                retention_days: None,
272                principal_id: by.to_owned(),
273            },
274        )?;
275        self.get_repo(to)
276    }
277
278    /// The copy half of [`Self::move_repository`]: the snapshot's export of
279    /// one repository (`twin::snapshot`), written into `to` through its
280    /// logged store instead of into a stream.
281    fn copy_repository(
282        &self,
283        from: &gunnar_proto::RepoPath,
284        to: &gunnar_proto::RepoPath,
285    ) -> anyhow::Result<()> {
286        let from_ref = gunnar_core::RepoRef::new(&from.namespace, &from.repo)?;
287        let to_ref = gunnar_core::RepoRef::new(&to.namespace, &to.repo)?;
288        let src_row = self
289            .stores
290            .catalog()
291            .get(&from_ref)?
292            .context("the repository to move is not in the catalog")?;
293        let dst_row = self
294            .stores
295            .catalog()
296            .get(&to_ref)?
297            .context("the new repository is not in the catalog")?;
298        let src = self.stores.repo_store(&src_row)?;
299        let dst = self.stores.repo_store(&dst_row)?;
300        let git = src.unlogged();
301        let mut updates = Vec::new();
302        let mut tips: Vec<Vec<u8>> = Vec::new();
303        for r in git.refs()? {
304            if r.name == "HEAD" {
305                continue;
306            }
307            if let Some(oid) = &r.oid {
308                tips.push(oid.clone());
309            }
310            updates.push(RefUpdate {
311                name: r.name,
312                target: r.oid.as_deref().map(hex::encode),
313                peeled: r.peeled.as_deref().map(hex::encode),
314                symref_target: r.symref_target,
315            });
316        }
317        if !tips.is_empty() {
318            let want: Vec<&[u8]> = tips.iter().map(Vec::as_slice).collect();
319            let objects = match git.select(&want, &[])? {
320                Some(set) => set.objects,
321                None => git.reachable(&want, &[])?,
322            };
323            let objs: Vec<&[u8]> = objects.iter().map(Vec::as_slice).collect();
324            let mut pack = Vec::new();
325            git.emit_pack(&objs, &[], &Caps::modern(), &mut pack)
326                .context("exporting the objects to move")?;
327            dst.serve()
328                .put_pack(&pack)
329                .context("writing the moved objects")?;
330        }
331        if !updates.is_empty() {
332            dst.serve()
333                .put_refs(&updates)
334                .context("writing the moved references")?;
335        }
336        if let Some(target) = git.head()?.and_then(|h| h.symref_target) {
337            dst.serve()
338                .set_head(&target)
339                .context("pointing the moved HEAD")?;
340        }
341        let lfs_dir = src.git_dir().join("lfs").join("objects");
342        let files = lfs_files(&lfs_dir)?;
343        if !files.is_empty() {
344            let store = crate::txlog_lfs::LoggedLfs::new(
345                gunnar_lfs::FsLfsStore::open_or_init(dst.git_dir())?,
346                Arc::clone(self.stores.txlog()),
347                format!("{}/{}", to.namespace, to.repo),
348            );
349            for path in files {
350                let Some(oid) = path
351                    .file_name()
352                    .and_then(|n| n.to_str())
353                    .and_then(|n| gunnar_lfs::Oid::parse(n).ok())
354                else {
355                    continue;
356                };
357                let mut file = std::fs::File::open(&path)
358                    .with_context(|| format!("opening LFS {}", path.display()))?;
359                let size = file.metadata()?.len();
360                let mut pending = store.begin_write(&oid, size)?;
361                std::io::copy(&mut file, &mut pending)
362                    .with_context(|| format!("copying LFS {}", path.display()))?;
363                pending.commit()?;
364            }
365        }
366        drop(src);
367        drop(dst);
368        self.stores.close_repo(&from_ref);
369        self.stores.close_repo(&to_ref);
370        Ok(())
371    }
372}
373
374impl ClanPlane for CoreControlPlane {
375    fn clan_create(
376        &self,
377        name: &str,
378        creator: &str,
379        contribution: u64,
380        room: ClanRoom,
381    ) -> ControlResult<gunnar_proto::Clan> {
382        if name.contains('/') {
383            return Err(ControlError::InvalidArgument(format!(
384                "{name:?}: a clan's name is one namespace, with no `/` in it"
385            )));
386        }
387        // The namespace first — the catalog's claim on the name.
388        match self.stores.catalog().create_namespace(name) {
389            Ok(_) => {}
390            Err(gunnar_core::Error::NamespaceExists(_)) => {
391                return Err(ControlError::AlreadyExists(format!(
392                    "a namespace called {name}"
393                )))
394            }
395            Err(err) => return Err(internal(err)),
396        }
397        match self
398            .stores
399            .auth()
400            .create_clan(name, creator, contribution, room_in(room))
401        {
402            Ok(clan) => Ok(self.clan_written(clan)),
403            Err(err) => {
404                // Made for this clan a moment ago, and empty: given back, so a
405                // refused clan leaves no name taken.
406                if let Err(undo) = self.stores.catalog().delete_namespace(name, false) {
407                    tracing::warn!(
408                        target: "gunnar::audit",
409                        event = "clan.namespace_not_given_back",
410                        clan = %name,
411                        reason = %undo,
412                        "a refused clan's namespace could not be removed again"
413                    );
414                }
415                Err(clan_error(err))
416            }
417        }
418    }
419
420    fn clan_get(&self, name: &str) -> ControlResult<Option<gunnar_proto::Clan>> {
421        Ok(self
422            .stores
423            .auth()
424            .clan(name)
425            .map_err(internal)?
426            .as_ref()
427            .map(|c| self.clan_view(c)))
428    }
429
430    fn clans_of(&self, principal: &str) -> ControlResult<Vec<gunnar_proto::Clan>> {
431        Ok(self
432            .stores
433            .auth()
434            .clans_of(principal)
435            .map_err(internal)?
436            .iter()
437            .map(|c| self.clan_view(c))
438            .collect())
439    }
440
441    fn clan_invite(
442        &self,
443        clan: &str,
444        by: &str,
445        principal: &str,
446    ) -> ControlResult<gunnar_proto::Clan> {
447        let row = self
448            .stores
449            .auth()
450            .invite_to_clan(clan, by, principal)
451            .map_err(clan_error)?;
452        Ok(self.clan_written(row))
453    }
454
455    fn clan_answer(
456        &self,
457        clan: &str,
458        principal: &str,
459        accept: bool,
460        contribution: u64,
461        room: ClanRoom,
462    ) -> ControlResult<gunnar_proto::Clan> {
463        let row = self
464            .stores
465            .auth()
466            .answer_clan_invite(clan, principal, accept, contribution, room_in(room))
467            .map_err(clan_error)?;
468        Ok(self.clan_written(row))
469    }
470
471    fn clan_withdraw(
472        &self,
473        clan: &str,
474        by: &str,
475        principal: &str,
476    ) -> ControlResult<gunnar_proto::Clan> {
477        let row = self
478            .stores
479            .auth()
480            .withdraw_clan_invite(clan, by, principal)
481            .map_err(clan_error)?;
482        Ok(self.clan_written(row))
483    }
484
485    fn clan_set_contribution(
486        &self,
487        clan: &str,
488        member: &str,
489        bytes: u64,
490        room: ClanRoom,
491        used: Option<u64>,
492    ) -> ControlResult<gunnar_proto::Clan> {
493        let row = self
494            .stores
495            .auth()
496            .set_clan_contribution(
497                clan,
498                member,
499                bytes,
500                room_in(room),
501                used,
502                &self.lapsed_in(clan)?,
503            )
504            .map_err(clan_error)?;
505        Ok(self.clan_written(row))
506    }
507
508    fn clan_leave(
509        &self,
510        clan: &str,
511        member: &str,
512        used: Option<u64>,
513    ) -> ControlResult<gunnar_proto::Clan> {
514        let row = self
515            .stores
516            .auth()
517            .leave_clan(clan, member, used, &self.lapsed_in(clan)?)
518            .map_err(clan_error)?;
519        Ok(self.clan_written(row))
520    }
521
522    fn clan_remove(
523        &self,
524        clan: &str,
525        by: &str,
526        member: &str,
527        used: Option<u64>,
528    ) -> ControlResult<gunnar_proto::Clan> {
529        let row = self
530            .stores
531            .auth()
532            .remove_from_clan(clan, by, member, used, &self.lapsed_in(clan)?)
533            .map_err(clan_error)?;
534        Ok(self.clan_written(row))
535    }
536
537    fn clan_set_admin(
538        &self,
539        clan: &str,
540        by: &str,
541        member: &str,
542        admin: bool,
543    ) -> ControlResult<gunnar_proto::Clan> {
544        let row = self
545            .stores
546            .auth()
547            .set_clan_admin(clan, by, member, admin)
548            .map_err(clan_error)?;
549        Ok(self.clan_written(row))
550    }
551
552    /// The admin's, and only of an EMPTY clan: no repository in its
553    /// namespace, live or deleted-and-still-held. The namespace goes first —
554    /// the catalog refuses a namespace that holds a repository, in its own
555    /// write, so a repository made a moment ago keeps the clan — and the clan
556    /// row after it.
557    fn clan_dissolve(&self, clan: &str, by: &str) -> ControlResult<gunnar_proto::Clan> {
558        let auth = self.stores.auth();
559        auth.clan_for(clan, by).map_err(clan_error)?;
560        let live = self
561            .stores
562            .catalog()
563            .list(Some(clan))
564            .map_err(internal)?
565            .len();
566        let held = gunnar_grpc::ControlPlane::list_deleted(self, Some(clan))?.len();
567        if live + held > 0 {
568            return Err(ControlError::FailedPrecondition(format!(
569                "clan {clan} still holds {live} repositor{} and {held} deleted one{} kept for their \
570                 window: its admin deletes and purges them before the clan can be dissolved",
571                if live == 1 { "y" } else { "ies" },
572                if held == 1 { "" } else { "s" },
573            )));
574        }
575        if self
576            .stores
577            .catalog()
578            .namespace(clan)
579            .map_err(internal)?
580            .is_some()
581        {
582            self.stores
583                .catalog()
584                .delete_namespace(clan, false)
585                .map_err(internal)?;
586            if let Err(err) = self.stores.flow().forget(clan) {
587                tracing::warn!(
588                    target: "gunnar::store",
589                    event = "flow.forget_failed",
590                    namespace = %clan,
591                    %err,
592                    "the clan's namespace was deleted but its activity series could not be removed"
593                );
594            }
595        }
596        let row = auth.dissolve_clan(clan, by).map_err(clan_error)?;
597        Ok(self.clan_written(row))
598    }
599
600    fn clan_room_left(&self, account: &str, room: ClanRoom) -> ControlResult<Option<u64>> {
601        self.stores
602            .auth()
603            .room_left(account, room_in(room))
604            .map_err(internal)
605    }
606
607    fn clan_given(&self, account: &str) -> ControlResult<u64> {
608        self.stores.auth().contributed_by(account).map_err(internal)
609    }
610
611    fn clan_offer_repo(
612        &self,
613        clan: &str,
614        by: &str,
615        repo: &str,
616        to: &str,
617    ) -> ControlResult<gunnar_proto::Clan> {
618        // The clan's admin first — an outsider learns nothing about the
619        // clan's repositories — then the repository must be the clan's.
620        self.stores.auth().clan_for(clan, by).map_err(clan_error)?;
621        self.get_repo(&gunnar_proto::RepoPath::new(clan, repo))?;
622        let row = self
623            .stores
624            .auth()
625            .offer_clan_repo(clan, by, repo, to)
626            .map_err(clan_error)?;
627        Ok(self.clan_written(row))
628    }
629
630    fn clan_repo_offer(
631        &self,
632        clan: &str,
633        repo: &str,
634    ) -> ControlResult<Option<gunnar_proto::RepoOffer>> {
635        Ok(self
636            .stores
637            .auth()
638            .clan_repo_offer(clan, repo)
639            .map_err(internal)?
640            .map(|offer| offer_out(clan, &offer, self.repo_stored(clan, repo))))
641    }
642
643    fn clan_offers_to(&self, to: &str) -> ControlResult<Vec<gunnar_proto::RepoOffer>> {
644        Ok(self
645            .stores
646            .auth()
647            .clan_offers_to(to)
648            .map_err(internal)?
649            .iter()
650            .map(|(clan, offer)| offer_out(clan, offer, self.repo_stored(clan, &offer.repo)))
651            .collect())
652    }
653
654    fn clan_withdraw_offer(
655        &self,
656        clan: &str,
657        by: &str,
658        repo: &str,
659    ) -> ControlResult<gunnar_proto::Clan> {
660        let row = self
661            .stores
662            .auth()
663            .withdraw_clan_repo_offer(clan, by, repo)
664            .map_err(clan_error)?;
665        Ok(self.clan_written(row))
666    }
667
668    fn clan_decline_offer(
669        &self,
670        clan: &str,
671        repo: &str,
672        to: &str,
673    ) -> ControlResult<gunnar_proto::RepoOffer> {
674        let offer = self
675            .stores
676            .auth()
677            .close_clan_repo_offer(clan, repo, to)
678            .map_err(clan_error)?;
679        self.stores.mirror_identity();
680        Ok(offer_out(clan, &offer, None))
681    }
682
683    /// The move, then the offer closed. A move that fails leaves the offer
684    /// open (and the repository where it was) for another try or a decline.
685    fn clan_take_offer(
686        &self,
687        clan: &str,
688        repo: &str,
689        to: &str,
690        by: &str,
691    ) -> ControlResult<(gunnar_proto::RepoOffer, gunnar_proto::Repo)> {
692        let stored = self.repo_stored(clan, repo);
693        let open = self
694            .stores
695            .auth()
696            .clan_repo_offer(clan, repo)
697            .map_err(internal)?
698            .filter(|o| o.to == to)
699            .ok_or_else(|| ControlError::NotFound(format!("an offer of {clan}/{repo} to {to}")))?;
700        let moved = self.move_repository(
701            &gunnar_proto::RepoPath::new(clan, repo),
702            &gunnar_proto::RepoPath::new(to, repo),
703            by,
704        )?;
705        let offer = self
706            .stores
707            .auth()
708            .close_clan_repo_offer(clan, repo, to)
709            .unwrap_or(open);
710        self.stores.mirror_identity();
711        Ok((offer_out(clan, &offer, stored), moved))
712    }
713
714    fn clan_role(
715        &self,
716        principal: &str,
717        namespace: &str,
718    ) -> ControlResult<Option<gunnar_proto::ClanRole>> {
719        Ok(self
720            .stores
721            .auth()
722            .clan_role(principal, namespace)
723            .map_err(internal)?
724            .map(|role| match role {
725                ClanRole::Admin => gunnar_proto::ClanRole::Admin,
726                ClanRole::Member => gunnar_proto::ClanRole::Member,
727                ClanRole::Invited => gunnar_proto::ClanRole::Invited,
728            }))
729    }
730}
731
732/// Every LFS object file under `dir` (the snapshot's walk, `twin::snapshot`).
733fn lfs_files(dir: &std::path::Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
734    let mut out = Vec::new();
735    let mut stack = vec![dir.to_path_buf()];
736    while let Some(d) = stack.pop() {
737        let entries = match std::fs::read_dir(&d) {
738            Ok(entries) => entries,
739            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
740            Err(e) => return Err(e).with_context(|| format!("listing {}", d.display())),
741        };
742        for entry in entries {
743            let entry = entry?;
744            let kind = entry.file_type()?;
745            if kind.is_dir() {
746                stack.push(entry.path());
747            } else if kind.is_file() {
748                out.push(entry.path());
749            }
750        }
751    }
752    out.sort();
753    Ok(out)
754}