Skip to main content

atelier_sdk/
engine.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use atelier_sdk_diff::{Diff, diff_listings};
4use futures::{AsyncReadExt, StreamExt};
5use jj_lib::backend::{CommitId, Signature, Timestamp, TreeValue};
6use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
7use jj_lib::default_backend_factories::{
8    default_backend_factories, default_working_copy_factories, default_working_copy_factory,
9};
10use jj_lib::git::{self, GitImportOptions};
11use jj_lib::gitignore::GitIgnoreFile;
12use jj_lib::matchers::{EverythingMatcher, NothingMatcher};
13use jj_lib::merged_tree::MergedTree;
14use jj_lib::object_id::ObjectId;
15use jj_lib::op_store::RefTarget;
16use jj_lib::ref_name::{RefName, WorkspaceNameBuf};
17use jj_lib::repo::{ReadonlyRepo, Repo};
18use jj_lib::repo_path::RepoPath;
19use jj_lib::rewrite::rebase_commit;
20use jj_lib::settings::UserSettings;
21use jj_lib::working_copy::SnapshotOptions;
22use jj_lib::workspace::{LockedWorkspace, Workspace as JjWorkspace};
23use pollster::block_on;
24use std::fs;
25use std::os::unix::fs::PermissionsExt;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use crate::config::Actor;
30use crate::error::{Error, config_err, engine_err};
31use crate::workspace::SKIP_NAMES;
32
33const NEW_FILE_SIZE_MAX: u64 = 50 * 1024 * 1024;
34
35/// The largest file the ladder loads to raise its fidelity. Bigger files
36/// stay at the binary rung — their deltas are still listed, just not
37/// projected or line-diffed — and the caller journals the degradation.
38pub(crate) const LADDER_FILE_SIZE_MAX: u64 = 8 * 1024 * 1024;
39// The ladder only ever re-reads files a snapshot accepted.
40const _: () = assert!(LADDER_FILE_SIZE_MAX <= NEW_FILE_SIZE_MAX);
41
42/// One immutable whole-workspace state in history, attributed to an actor.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Snapshot {
45    /// The snapshot's stable identity.
46    pub id: String,
47    /// The actor the snapshot is attributed to.
48    pub actor: String,
49    /// When the snapshot was taken, in unix milliseconds.
50    pub at_ms: i64,
51    /// The ids of the snapshot's parents in history.
52    pub parents: Vec<String>,
53}
54
55/// The two trees a diff spans, kept opaque so jj types stay inside the
56/// engine. The ladder hands it back to read file content off either side.
57pub(crate) struct DiffSides {
58    before: MergedTree,
59    after: MergedTree,
60}
61
62/// A file's content on one side of a diff: its content id and its bytes.
63pub(crate) struct FileBlob {
64    pub id: String,
65    pub bytes: Vec<u8>,
66}
67
68/// What one side of a diff holds at a path.
69pub(crate) enum Side {
70    /// The path is absent or not a plain file on this side.
71    Absent,
72    /// The file exceeds [`LADDER_FILE_SIZE_MAX`]; its delta stays at the
73    /// binary rung and the caller journals the degradation.
74    TooLarge,
75    Blob(FileBlob),
76}
77
78/// What one undo attempt did to the shared line (ADR-0011).
79pub(crate) enum StepBack {
80    /// The line stepped back off the landed snapshot to `restored`.
81    Stepped { restored: String },
82    /// The line already sits on the landed snapshot's parent: a prior
83    /// attempt stepped it; nothing to do.
84    AlreadyStepped,
85    /// The line moved past the landing; `head` is what sits on it now.
86    LineMoved { head: String },
87}
88
89/// What one landing attempt did to the shared line.
90pub(crate) enum LandOutcome {
91    Landed {
92        snapshot: String,
93    },
94    /// The rebase produced conflicts; nothing moved — the shared line
95    /// never carries a conflicted state (ADR-0007).
96    Conflicted,
97}
98
99/// How a snapshot enters history: the shared line stacks a new commit per
100/// state; a session amends its one change so the change id survives.
101enum SnapshotStyle {
102    Stack,
103    Amend,
104}
105
106/// The jj-backed engine: the only place jj types are allowed to appear.
107pub(crate) struct Engine {
108    jj: JjWorkspace,
109    repo: Arc<ReadonlyRepo>,
110    _settings: UserSettings,
111    /// Mount names outside this engine's world: never snapshotted as its
112    /// content, however they appear (ADR-0009).
113    boundary: Vec<String>,
114}
115
116impl Engine {
117    /// Create a colocated-git workspace store rooted at `root`; paths under
118    /// the `boundary` names are outside this engine's world.
119    pub fn init(root: &Path, actor: &Actor, boundary: &[String]) -> Result<Self, Error> {
120        let settings = build_settings(actor)?;
121        let (jj, repo) = block_on(JjWorkspace::init_colocated_git(
122            &settings,
123            root,
124            gix_hash::Kind::Sha1,
125        ))
126        .map_err(engine_err)?;
127        Ok(Self {
128            jj,
129            repo,
130            _settings: settings,
131            boundary: boundary.to_vec(),
132        })
133    }
134
135    /// Load the workspace store already present at `root`.
136    pub fn open(root: &Path, actor: &Actor, boundary: &[String]) -> Result<Self, Error> {
137        let settings = build_settings(actor)?;
138        let jj = JjWorkspace::load(
139            &settings,
140            root,
141            &default_backend_factories(),
142            &default_working_copy_factories(),
143        )
144        .map_err(engine_err)?;
145        let repo = block_on(jj.repo_loader().load_at_head()).map_err(engine_err)?;
146        Ok(Self {
147            jj,
148            repo,
149            _settings: settings,
150            boundary: boundary.to_vec(),
151        })
152    }
153
154    /// Reload at the current operation head, folding in operations other
155    /// processes (the CLI beside a server) committed since this handle
156    /// loaded.
157    pub fn refresh(&mut self) -> Result<(), Error> {
158        self.repo = block_on(self.jj.repo_loader().load_at_head()).map_err(engine_err)?;
159        Ok(())
160    }
161
162    /// Adopt the git repository already at `root`: jj on the existing git
163    /// store, its history preserved, HEAD's tree checked out as the
164    /// working copy's parent — the repo stays a real repo plain git
165    /// pushes (ADR-0009: adopt, never import).
166    pub fn adopt_git(root: &Path, actor: &Actor, boundary: &[String]) -> Result<Self, Error> {
167        block_on(Self::adopt_git_async(root, actor, boundary))
168    }
169
170    async fn adopt_git_async(
171        root: &Path,
172        actor: &Actor,
173        boundary: &[String],
174    ) -> Result<Self, Error> {
175        let settings = build_settings(actor)?;
176        let (mut jj, repo) = JjWorkspace::init_external_git(&settings, root, &root.join(".git"))
177            .await
178            .map_err(engine_err)?;
179        let mut tx = repo.start_transaction();
180        git::import_head(tx.repo_mut()).await.map_err(engine_err)?;
181        let options = GitImportOptions {
182            abandon_unreachable_commits: false,
183            record_synthetic_predecessors: false,
184            remote_auto_track_bookmarks: std::collections::HashMap::new(),
185        };
186        git::import_refs(tx.repo_mut(), &options)
187            .await
188            .map_err(engine_err)?;
189        let head = tx.repo_mut().view().git_head().as_normal().cloned();
190        let name = jj.workspace_name().to_owned();
191        let wc_commit = match head {
192            // The working copy continues the adopted history: HEAD's tree,
193            // HEAD as parent.
194            Some(head_id) => {
195                let head = tx
196                    .repo_mut()
197                    .store()
198                    .get_commit(&head_id)
199                    .map_err(engine_err)?;
200                let wc_commit = tx
201                    .repo_mut()
202                    .new_commit(vec![head_id], head.tree())
203                    .set_author(signature(actor))
204                    .write()
205                    .await
206                    .map_err(engine_err)?;
207                tx.repo_mut()
208                    .set_wc_commit(name, wc_commit.id().clone())
209                    .map_err(engine_err)?;
210                tx.repo_mut()
211                    .rebase_descendants()
212                    .await
213                    .map_err(engine_err)?;
214                Some(wc_commit)
215            }
216            // An empty repo (no commits yet) adopts as a fresh line.
217            None => None,
218        };
219        if let Some(wc_commit) = &wc_commit {
220            git::reset_head(tx.repo_mut(), wc_commit)
221                .await
222                .map_err(engine_err)?;
223        }
224        let repo = tx.commit("adopt git repo").await.map_err(engine_err)?;
225        if let Some(wc_commit) = &wc_commit {
226            jj.check_out(repo.op_id().clone(), None, wc_commit)
227                .await
228                .map_err(engine_err)?;
229        }
230        Ok(Self {
231            jj,
232            repo,
233            _settings: settings,
234            boundary: boundary.to_vec(),
235        })
236    }
237
238    /// Snapshot outstanding edits. Records a new commit only when the tree
239    /// changed; returns the new snapshot id in that case.
240    pub fn snapshot(&mut self) -> Result<Option<String>, Error> {
241        block_on(self.snapshot_with(&SnapshotStyle::Stack))
242    }
243
244    /// Snapshot outstanding edits by amending this workspace's commit: the
245    /// session's change id survives while its tree advances.
246    pub fn snapshot_amend(&mut self) -> Result<Option<String>, Error> {
247        block_on(self.snapshot_with(&SnapshotStyle::Amend))
248    }
249
250    async fn snapshot_with(&mut self, style: &SnapshotStyle) -> Result<Option<String>, Error> {
251        let name = self.jj.workspace_name().to_owned();
252        let wc_id = match self.repo.view().get_wc_commit_id(&name) {
253            Some(id) => id.clone(),
254            None => return Err(Error::Engine("no working-copy commit".to_owned())),
255        };
256        let options = snapshot_options(base_ignores(&self.boundary)?);
257
258        let mut locked = self
259            .jj
260            .start_working_copy_mutation()
261            .await
262            .map_err(engine_err)?;
263
264        let (new_tree, stats) = match locked.locked_wc().snapshot(&options).await {
265            Ok(result) => result,
266            Err(err) => {
267                release_at_old_operation(locked).await?;
268                return Err(engine_err(err));
269            }
270        };
271        if !stats.invalid_utf8_paths.is_empty() {
272            release_at_old_operation(locked).await?;
273            return Err(Error::Engine(
274                "working copy has paths with invalid utf-8 names".to_owned(),
275            ));
276        }
277
278        let wc_commit = self.repo.store().get_commit(&wc_id).map_err(engine_err)?;
279        if new_tree.tree_ids() == wc_commit.tree_ids() {
280            release_at_old_operation(locked).await?;
281            return Ok(None);
282        }
283
284        let mut tx = self.repo.start_transaction();
285        tx.set_is_snapshot(true);
286        let new_commit = match style {
287            SnapshotStyle::Stack => tx
288                .repo_mut()
289                .new_commit(vec![wc_id], new_tree)
290                .write()
291                .await
292                .map_err(engine_err)?,
293            SnapshotStyle::Amend => tx
294                .repo_mut()
295                .rewrite_commit(&wc_commit)
296                .set_tree(new_tree)
297                .write()
298                .await
299                .map_err(engine_err)?,
300        };
301        let new_id = new_commit.id().clone();
302        tx.repo_mut()
303            .set_wc_commit(name, new_id.clone())
304            .map_err(engine_err)?;
305        tx.repo_mut()
306            .rebase_descendants()
307            .await
308            .map_err(engine_err)?;
309        // The Stack style moves a shared line: keep the colocated git
310        // HEAD on it, so plain git sees what jj wrote (PRD story 14). The
311        // Amend style is a session's — sessions share the root's git repo
312        // and must not steal its HEAD.
313        if let SnapshotStyle::Stack = style {
314            git::reset_head(tx.repo_mut(), &new_commit)
315                .await
316                .map_err(engine_err)?;
317        }
318        let repo = tx.commit("snapshot").await.map_err(engine_err)?;
319        locked
320            .finish(repo.op_id().clone())
321            .await
322            .map_err(engine_err)?;
323        self.repo = repo;
324        Ok(Some(new_id.hex()))
325    }
326
327    /// The ancestor chain of the working-copy commit, newest first.
328    pub fn log(&self, limit: usize) -> Result<Vec<Snapshot>, Error> {
329        let name = self.jj.workspace_name().to_owned();
330        let wc_id = match self.repo.view().get_wc_commit_id(&name) {
331            Some(id) => id.clone(),
332            None => return Err(Error::Engine("no working-copy commit".to_owned())),
333        };
334        let root = self.repo.store().root_commit_id().clone();
335        let mut out = Vec::new();
336        let mut current = Some(wc_id);
337        while let Some(id) = current {
338            if out.len() >= limit {
339                break;
340            }
341            let commit = self.repo.store().get_commit(&id).map_err(engine_err)?;
342            let parents: Vec<String> = commit
343                .parent_ids()
344                .iter()
345                .filter(|parent| **parent != root)
346                .map(ObjectId::hex)
347                .collect();
348            let author = commit.author();
349            out.push(Snapshot {
350                id: id.hex(),
351                actor: author.name.clone(),
352                at_ms: author.timestamp.timestamp.0,
353                parents,
354            });
355            current = commit
356                .parent_ids()
357                .iter()
358                .find(|parent| **parent != root)
359                .cloned();
360        }
361        Ok(out)
362    }
363
364    /// This workspace's working-copy commit: the head of its line.
365    pub fn head(&self) -> Result<String, Error> {
366        Ok(self.wc_commit_id()?.hex())
367    }
368
369    /// The first parent of `id` — for a session's change, the shared-line
370    /// snapshot it forked from.
371    pub fn parent_of(&self, id: &str) -> Result<String, Error> {
372        let commit = self.commit_at(id)?;
373        match commit.parent_ids().first() {
374            Some(parent) => Ok(parent.hex()),
375            None => Err(Error::Engine(format!("snapshot {id} has no parent"))),
376        }
377    }
378
379    /// Create a session's own jj workspace at `root`: a working copy of the
380    /// shared head and a fresh change there, authored by `actor`. The new
381    /// change's id.
382    pub fn create_session_workspace(
383        &mut self,
384        root: &Path,
385        name: &str,
386        actor: &Actor,
387    ) -> Result<String, Error> {
388        block_on(self.create_session_workspace_async(root, name, actor))
389    }
390
391    async fn create_session_workspace_async(
392        &mut self,
393        root: &Path,
394        name: &str,
395        actor: &Actor,
396    ) -> Result<String, Error> {
397        let head_id = self.wc_commit_id()?;
398        let head = self.repo.store().get_commit(&head_id).map_err(engine_err)?;
399        fs::create_dir_all(root)?;
400        let (mut session_ws, repo) = JjWorkspace::init_workspace_with_existing_repo(
401            root,
402            self.jj.repo_path(),
403            &self.repo,
404            &*default_working_copy_factory(),
405            WorkspaceNameBuf::from(name),
406        )
407        .await
408        .map_err(engine_err)?;
409        let mut tx = repo.start_transaction();
410        let wc_commit = tx
411            .repo_mut()
412            .new_commit(vec![head_id], head.tree())
413            .set_author(signature(actor))
414            .write()
415            .await
416            .map_err(engine_err)?;
417        tx.repo_mut()
418            .edit(WorkspaceNameBuf::from(name), &wc_commit)
419            .await
420            .map_err(engine_err)?;
421        // `edit` abandons the placeholder commit the workspace registration
422        // created; the abandonment is a rewrite the transaction insists is
423        // propagated.
424        tx.repo_mut()
425            .rebase_descendants()
426            .await
427            .map_err(engine_err)?;
428        let repo = tx.commit("open session").await.map_err(engine_err)?;
429        session_ws
430            .check_out(repo.op_id().clone(), None, &wc_commit)
431            .await
432            .map_err(engine_err)?;
433        self.repo = repo;
434        Ok(wc_commit.change_id().hex())
435    }
436
437    /// Land `tip` onto the shared line: rebase it onto the head, refuse a
438    /// conflicted result, else advance the line and the working copy. The
439    /// caller holds the landing lease.
440    pub fn land(&mut self, tip: &str, bookmark: &str) -> Result<LandOutcome, Error> {
441        block_on(self.land_async(tip, bookmark))
442    }
443
444    async fn land_async(&mut self, tip: &str, bookmark: &str) -> Result<LandOutcome, Error> {
445        let name = self.jj.workspace_name().to_owned();
446        let head_id = self.wc_commit_id()?;
447        let tip_commit = self.commit_at(tip)?;
448        let mut tx = self.repo.start_transaction();
449        let rebased = rebase_commit(tx.repo_mut(), tip_commit, vec![head_id])
450            .await
451            .map_err(engine_err)?;
452        if rebased.has_conflict() {
453            return Ok(LandOutcome::Conflicted);
454        }
455        tx.repo_mut()
456            .set_wc_commit(name, rebased.id().clone())
457            .map_err(engine_err)?;
458        tx.repo_mut()
459            .rebase_descendants()
460            .await
461            .map_err(engine_err)?;
462        // The line advanced; keep the colocated git HEAD on it.
463        git::reset_head(tx.repo_mut(), &rebased)
464            .await
465            .map_err(engine_err)?;
466        // The landed line must be pushable with plain git: move the
467        // bookmark to the landed snapshot and export it as a git branch.
468        // The working-copy commit itself never enters a branch, so the
469        // bookmark - not HEAD - is what a push publishes.
470        tx.repo_mut().set_local_bookmark_target(
471            RefName::new(bookmark),
472            RefTarget::normal(rebased.id().clone()),
473        );
474        let exported = git::export_refs(tx.repo_mut()).map_err(engine_err)?;
475        if !exported.failed_bookmarks.is_empty() {
476            return Err(Error::Engine(format!(
477                "bookmark {bookmark:?} failed to export: {:?}",
478                exported.failed_bookmarks
479            )));
480        }
481        let repo = tx.commit("land").await.map_err(engine_err)?;
482        self.repo = repo;
483        self.jj
484            .check_out(self.repo.op_id().clone(), None, &rebased)
485            .await
486            .map_err(engine_err)?;
487        Ok(LandOutcome::Landed {
488            snapshot: rebased.id().hex(),
489        })
490    }
491
492    /// Step the line back off `landed` to its parent (ADR-0011): the
493    /// working copy, the colocated git HEAD, and the bookmark all return;
494    /// the landed snapshot stays in history. The caller holds the landing
495    /// lease. Idempotent by outcome: a line already stepped says so, a
496    /// line that moved past the landing refuses with its head.
497    pub fn step_back(&mut self, landed: &str, bookmark: &str) -> Result<StepBack, Error> {
498        block_on(self.step_back_async(landed, bookmark))
499    }
500
501    async fn step_back_async(&mut self, landed: &str, bookmark: &str) -> Result<StepBack, Error> {
502        let name = self.jj.workspace_name().to_owned();
503        let head = self.wc_commit_id()?;
504        let landed_commit = self.commit_at(landed)?;
505        let Some(parent_id) = landed_commit.parent_ids().first() else {
506            return Err(Error::Engine(format!(
507                "the landed snapshot {landed} has no parent to step back to"
508            )));
509        };
510        if head == *parent_id {
511            return Ok(StepBack::AlreadyStepped);
512        }
513        if head.hex() != landed {
514            return Ok(StepBack::LineMoved { head: head.hex() });
515        }
516        let parent = self
517            .repo
518            .store()
519            .get_commit(parent_id)
520            .map_err(engine_err)?;
521        let mut tx = self.repo.start_transaction();
522        tx.repo_mut()
523            .set_wc_commit(name, parent_id.clone())
524            .map_err(engine_err)?;
525        // The line stepped back; HEAD and the bookmark follow so plain git
526        // never publishes the undone head as the newest state.
527        git::reset_head(tx.repo_mut(), &parent)
528            .await
529            .map_err(engine_err)?;
530        tx.repo_mut().set_local_bookmark_target(
531            RefName::new(bookmark),
532            RefTarget::normal(parent_id.clone()),
533        );
534        let exported = git::export_refs(tx.repo_mut()).map_err(engine_err)?;
535        if !exported.failed_bookmarks.is_empty() {
536            return Err(Error::Engine(format!(
537                "bookmark {bookmark:?} failed to export: {:?}",
538                exported.failed_bookmarks
539            )));
540        }
541        let repo = tx.commit("undo").await.map_err(engine_err)?;
542        self.repo = repo;
543        self.jj
544            .check_out(self.repo.op_id().clone(), None, &parent)
545            .await
546            .map_err(engine_err)?;
547        Ok(StepBack::Stepped {
548            restored: parent_id.hex(),
549        })
550    }
551
552    /// Whether `id`'s tree differs from its first parent's — the session
553    /// change carries work on this line.
554    pub fn tree_changed(&self, id: &str) -> Result<bool, Error> {
555        let commit = self.commit_at(id)?;
556        let Some(parent_id) = commit.parent_ids().first() else {
557            return Ok(true);
558        };
559        let parent = self
560            .repo
561            .store()
562            .get_commit(parent_id)
563            .map_err(engine_err)?;
564        Ok(commit.tree_ids() != parent.tree_ids())
565    }
566
567    fn wc_commit_id(&self) -> Result<CommitId, Error> {
568        let name = self.jj.workspace_name().to_owned();
569        match self.repo.view().get_wc_commit_id(&name) {
570            Some(id) => Ok(id.clone()),
571            None => Err(Error::Engine("no working-copy commit".to_owned())),
572        }
573    }
574
575    fn commit_at(&self, id: &str) -> Result<jj_lib::commit::Commit, Error> {
576        let Some(commit_id) = CommitId::try_from_hex(id) else {
577            return Err(Error::Engine(format!("not a snapshot id: {id}")));
578        };
579        self.repo.store().get_commit(&commit_id).map_err(engine_err)
580    }
581
582    /// Diff the latest snapshot against its first parent (empty tree if
583    /// none), returning the binary-rung diff plus the sides it spans.
584    pub fn diff_latest(&self) -> Result<(Diff, DiffSides), Error> {
585        block_on(self.diff_latest_async())
586    }
587
588    async fn diff_latest_async(&self) -> Result<(Diff, DiffSides), Error> {
589        let name = self.jj.workspace_name().to_owned();
590        let wc_id = match self.repo.view().get_wc_commit_id(&name) {
591            Some(id) => id.clone(),
592            None => return Err(Error::Engine("no working-copy commit".to_owned())),
593        };
594        let root = self.repo.store().root_commit_id().clone();
595        let commit = self.repo.store().get_commit(&wc_id).map_err(engine_err)?;
596        let new_tree = commit.tree();
597        let parent = commit
598            .parent_ids()
599            .iter()
600            .find(|parent| **parent != root)
601            .cloned();
602        let old_tree = match parent {
603            Some(parent_id) => self
604                .repo
605                .store()
606                .get_commit(&parent_id)
607                .map_err(engine_err)?
608                .tree(),
609            None => self.empty_tree()?,
610        };
611        self.tree_diff(old_tree, new_tree).await
612    }
613
614    /// Diff two snapshots by id: `before` against `after`, returning the
615    /// binary-rung diff plus the sides it spans.
616    pub fn diff_between(&self, before: &str, after: &str) -> Result<(Diff, DiffSides), Error> {
617        block_on(self.diff_between_async(before, after))
618    }
619
620    async fn diff_between_async(
621        &self,
622        before: &str,
623        after: &str,
624    ) -> Result<(Diff, DiffSides), Error> {
625        let old_tree = self.tree_at(before)?;
626        let new_tree = self.tree_at(after)?;
627        self.tree_diff(old_tree, new_tree).await
628    }
629
630    /// The file at `path` on each side of the diff.
631    pub fn read_file_sides(&self, sides: &DiffSides, path: &str) -> Result<(Side, Side), Error> {
632        block_on(async {
633            let path = RepoPath::from_internal_string(path).map_err(engine_err)?;
634            let before = self.file_blob(&sides.before, path).await?;
635            let after = self.file_blob(&sides.after, path).await?;
636            Ok((before, after))
637        })
638    }
639
640    async fn file_blob(&self, tree: &MergedTree, path: &RepoPath) -> Result<Side, Error> {
641        let value = tree.path_value(path).await.map_err(engine_err)?;
642        let Some(Some(TreeValue::File { id, .. })) = value.as_resolved() else {
643            return Ok(Side::Absent);
644        };
645        let reader = self
646            .repo
647            .store()
648            .read_file(path, id)
649            .await
650            .map_err(engine_err)?;
651        let mut bytes = Vec::new();
652        reader
653            .take(LADDER_FILE_SIZE_MAX + 1)
654            .read_to_end(&mut bytes)
655            .await
656            .map_err(engine_err)?;
657        if bytes.len() as u64 > LADDER_FILE_SIZE_MAX {
658            return Ok(Side::TooLarge);
659        }
660        Ok(Side::Blob(FileBlob {
661            id: id.hex(),
662            bytes,
663        }))
664    }
665
666    async fn tree_diff(
667        &self,
668        old_tree: MergedTree,
669        new_tree: MergedTree,
670    ) -> Result<(Diff, DiffSides), Error> {
671        let mut before = BTreeMap::new();
672        let mut after = BTreeMap::new();
673        let mut stream = old_tree.diff_stream(&new_tree, &EverythingMatcher);
674        while let Some(entry) = stream.next().await {
675            let path = entry.path.as_internal_file_string().to_owned();
676            let values = entry.values.map_err(engine_err)?;
677            if values.before.is_present() {
678                before.insert(path.clone(), format!("{:?}", values.before));
679            }
680            if values.after.is_present() {
681                after.insert(path, format!("{:?}", values.after));
682            }
683        }
684        drop(stream);
685        let diff = diff_listings(&before, &after);
686        Ok((
687            diff,
688            DiffSides {
689                before: old_tree,
690                after: new_tree,
691            },
692        ))
693    }
694
695    fn tree_at(&self, id: &str) -> Result<MergedTree, Error> {
696        Ok(self.commit_at(id)?.tree())
697    }
698
699    /// Materialize snapshot `id`'s tree at `dest` as a mirror: files are
700    /// written (executable bits kept, symlinks recreated), and anything
701    /// under `dest` the tree lacks is removed — except the engine-internal
702    /// names, which are never touched (ADR-0010).
703    pub fn export_tree(&self, id: &str, dest: &Path) -> Result<(), Error> {
704        block_on(self.export_tree_async(id, dest))
705    }
706
707    async fn export_tree_async(&self, id: &str, dest: &Path) -> Result<(), Error> {
708        let empty = self.empty_tree()?;
709        let tree = self.tree_at(id)?;
710        let mut kept: BTreeSet<String> = BTreeSet::new();
711        let mut stream = empty.diff_stream(&tree, &EverythingMatcher);
712        while let Some(entry) = stream.next().await {
713            let values = entry.values.map_err(engine_err)?;
714            let rel = entry.path.as_internal_file_string().to_owned();
715            let Some(value) = values.after.as_resolved() else {
716                return Err(Error::Engine(format!("conflicted tree entry at {rel}")));
717            };
718            let Some(value) = value else {
719                continue;
720            };
721            let target = dest.join(&rel);
722            if let Some(parent) = target.parent() {
723                fs::create_dir_all(parent)?;
724            }
725            match value {
726                TreeValue::File { id, executable, .. } => {
727                    let mut reader = self
728                        .repo
729                        .store()
730                        .read_file(&entry.path, id)
731                        .await
732                        .map_err(engine_err)?;
733                    let mut bytes = Vec::new();
734                    reader.read_to_end(&mut bytes).await.map_err(engine_err)?;
735                    fs::write(&target, &bytes)?;
736                    if *executable {
737                        let mut permissions = fs::metadata(&target)?.permissions();
738                        permissions.set_mode(0o755);
739                        fs::set_permissions(&target, permissions)?;
740                    }
741                }
742                TreeValue::Symlink(id) => {
743                    let link = self
744                        .repo
745                        .store()
746                        .read_symlink(&entry.path, id)
747                        .await
748                        .map_err(engine_err)?;
749                    if target.symlink_metadata().is_ok() {
750                        fs::remove_file(&target)?;
751                    }
752                    std::os::unix::fs::symlink(&link, &target)?;
753                }
754                other => {
755                    return Err(Error::Engine(format!("cannot export {rel}: {other:?}")));
756                }
757            }
758            kept.insert(rel);
759        }
760        drop(stream);
761        remove_unkept(dest, &kept)
762    }
763
764    fn empty_tree(&self) -> Result<MergedTree, Error> {
765        let root = self.repo.store().root_commit_id().clone();
766        let commit = self.repo.store().get_commit(&root).map_err(engine_err)?;
767        Ok(commit.tree())
768    }
769}
770
771fn build_settings(actor: &Actor) -> Result<UserSettings, Error> {
772    #[derive(serde::Serialize)]
773    struct UserConfig<'a> {
774        user: UserSection<'a>,
775    }
776
777    #[derive(serde::Serialize)]
778    struct UserSection<'a> {
779        name: &'a str,
780        email: String,
781    }
782
783    let mut config = StackedConfig::with_defaults();
784    let text = toml::to_string(&UserConfig {
785        user: UserSection {
786            name: &actor.name,
787            email: format!("{}@atelier.local", actor.name),
788        },
789    })
790    .map_err(config_err)?;
791    let layer = ConfigLayer::parse(ConfigSource::User, &text).map_err(config_err)?;
792    config.add_layer(layer);
793    UserSettings::from_config(config).map_err(config_err)
794}
795
796/// The commit signature attributing a session's change to its actor; the
797/// synthetic address keeps the git backend satisfied, as in
798/// [`build_settings`].
799fn signature(actor: &Actor) -> Signature {
800    Signature {
801        name: actor.name.clone(),
802        email: format!("{}@atelier.local", actor.name),
803        timestamp: Timestamp::now(),
804    }
805}
806
807/// The engine's boundary as one virtual root .gitignore: its own internals
808/// plus the mount names it must never version (anchored, so a nested
809/// directory that merely shares a mount's name stays content).
810/// Release a locked working copy at the operation it started from: the
811/// snapshot found nothing to write, or refused.
812async fn release_at_old_operation(mut locked: LockedWorkspace<'_>) -> Result<(), Error> {
813    let operation = locked.locked_wc().old_operation_id().clone();
814    locked.finish(operation).await.map_err(engine_err)?;
815    Ok(())
816}
817
818/// How every snapshot walks the working copy: track everything inside the
819/// boundary, force nothing, refuse files past the snapshot cap.
820fn snapshot_options(base_ignores: Arc<GitIgnoreFile>) -> SnapshotOptions<'static> {
821    SnapshotOptions {
822        base_ignores,
823        progress: None,
824        start_tracking_matcher: &EverythingMatcher,
825        force_tracking_matcher: &NothingMatcher,
826        max_new_file_size: NEW_FILE_SIZE_MAX,
827    }
828}
829
830fn base_ignores(boundary: &[String]) -> Result<Arc<GitIgnoreFile>, Error> {
831    let mut rules = String::from(".atelier/\n.git/\n.jj/\n");
832    for name in boundary {
833        rules.push('/');
834        rules.push_str(name);
835        rules.push_str("/\n");
836    }
837    GitIgnoreFile::empty()
838        .chain(RepoPath::root(), Path::new(".gitignore"), rules.as_bytes())
839        .map_err(engine_err)
840}
841
842/// Remove everything under `dir` the exported tree lacks, skipping the
843/// engine-internal names at any depth; empty directories vanish with
844/// their contents. `root` anchors the tree-relative paths in `kept`.
845fn remove_unkept(root: &Path, kept: &BTreeSet<String>) -> Result<(), Error> {
846    // An explicit work stack bounds the walk by entry count, never call
847    // depth; directories prune deepest-first so an emptied child empties
848    // its parent in turn.
849    let mut directories: Vec<PathBuf> = Vec::new();
850    let mut pending = vec![root.to_path_buf()];
851    while let Some(dir) = pending.pop() {
852        for entry in fs::read_dir(&dir)? {
853            let entry = entry?;
854            let name = entry.file_name();
855            let Some(name) = name.to_str() else {
856                return Err(Error::Engine(format!(
857                    "cannot mirror over a non-utf8 name at {}",
858                    entry.path().display()
859                )));
860            };
861            if SKIP_NAMES.contains(&name) {
862                continue;
863            }
864            let path = entry.path();
865            if entry.file_type()?.is_dir() {
866                directories.push(path.clone());
867                pending.push(path);
868            } else {
869                let rel = path
870                    .strip_prefix(root)
871                    .map_err(engine_err)?
872                    .components()
873                    .map(|component| component.as_os_str().to_string_lossy())
874                    .collect::<Vec<_>>()
875                    .join("/");
876                if !kept.contains(&rel) {
877                    fs::remove_file(&path)?;
878                }
879            }
880        }
881    }
882    directories.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
883    for dir in directories {
884        if fs::read_dir(&dir)?.next().is_none() {
885            fs::remove_dir(&dir)?;
886        }
887    }
888    Ok(())
889}