Skip to main content

newgit_core/
manager.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use camino::{Utf8Path, Utf8PathBuf};
4use chrono::Utc;
5
6use crate::branch::{
7    BranchInstance, ResourceBinding, ResourceStatus, TrackerBinding, branch_slug, validate_name,
8};
9use crate::checkpoint::{
10    CheckpointLog, CheckpointReason, CheckpointRecord, RecoveryRecord, ResourceState,
11    RestoreFailure, SourceState, TrackerState,
12};
13use crate::cleanup::{
14    ArchivedCheckpoints, CleanupOutcome, FinalizedInstance, HookDetail, HookOutcome, PrunedRev,
15    PurgedCheckpoints, SnapshotRoots, lane_revs, may_tear_down, orphan_workspaces,
16};
17use crate::config::ProjectConfig;
18use crate::error::{NewgitError, Result};
19use crate::export::{self, ExportFilter, ExportPlan, prepare_destination};
20use crate::exports::{RenderContext, render, unresolved_placeholder};
21use crate::lane::{TrackerLane, clear_owned_paths, copy_file};
22use crate::materializer::{Materializer, RealDirMaterializer, exclude_tracker_paths};
23use crate::ports;
24use crate::render::{self, RenderRecord};
25use crate::resource::{
26    Captures, CheckpointMode, GraphProblem, ResourceDefinition, RestoreMode, parse_captures,
27    resolve_order,
28};
29use crate::source::GitSource;
30use crate::store::MetadataStore;
31use crate::supervisor::{StopOutcome, Supervisor, run_captured, run_foreground};
32use crate::templates::resource_template;
33use crate::tracker::{Storage, TrackerDefinition, collect_files, collect_owned_files, content_rev};
34
35/// Orchestrates branch-instance lifecycle against one store.
36#[derive(Debug)]
37pub struct BranchManager {
38    store: MetadataStore,
39    config: ProjectConfig,
40    source: GitSource,
41    trackers: Vec<TrackerDefinition>,
42    resources: Vec<ResourceDefinition>,
43    /// Resource names, dependencies before dependents. Resources whose
44    /// dependencies are unresolved are still ordered; see `graph_problems`.
45    resource_order: Vec<String>,
46    /// Why the dependency graph does not hold together, if it doesn't.
47    /// Commands that build the graph warn; commands that act on it refuse.
48    graph_problems: Vec<GraphProblem>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SpawnOutcome {
53    pub branch: BranchInstance,
54    pub record_path: Utf8PathBuf,
55    /// False when the instance attached to a pre-existing source branch.
56    pub created_source_branch: bool,
57    pub trackers: Vec<TrackerBindOutcome>,
58    pub resources: Vec<ResourceBindOutcome>,
59}
60
61/// How a resource was bound at spawn.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ResourceBindOutcome {
64    pub name: String,
65    pub ports: BTreeMap<String, u16>,
66    pub status: ResourceStatus,
67    /// Present when a `prepare` action ran: (succeeded, log path).
68    pub prepare: Option<(bool, Utf8PathBuf)>,
69    /// Resource dependencies that prevented `prepare` from running.
70    pub blocked_by: Vec<String>,
71    /// Export names `prepare` published through `captures`.
72    pub captured: Vec<String>,
73    /// One warning per declared capture `prepare` never emitted.
74    pub missing_captures: Vec<String>,
75    /// Files rendered before `prepare`, and what it cost to render them.
76    pub rendered: Vec<RenderOutcome>,
77    /// Why a render failed, when one did. A render failure blocks `prepare`
78    /// the way a failed dependency does: a `prepare` run against unrendered
79    /// config would start a service on the wrong port.
80    pub render_error: Option<String>,
81}
82
83/// Rendered files temporarily reverted to committed values, and the content
84/// to put back. The workspace must end a capture exactly as it started it —
85/// the instance is still running against those rendered values.
86#[derive(Debug, Default)]
87struct RenderedRestore {
88    files: Vec<(Utf8PathBuf, String)>,
89}
90
91impl RenderedRestore {
92    fn restore(self) -> Result<()> {
93        for (path, contents) in self.files {
94            std::fs::write(&path, contents).map_err(|source| NewgitError::io(path, source))?;
95        }
96        Ok(())
97    }
98}
99
100/// One rendered file.
101///
102/// Carries no warning about lost hand edits: at bind there are none, and a
103/// caveat printed when nothing is wrong is silent at the moment something
104/// is. That report lives in `render_drift`, which speaks at checkpoint and
105/// before a re-render, naming the file and how much of it is about to go.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct RenderOutcome {
108    pub path: Utf8PathBuf,
109    pub replacements: usize,
110    /// The tracker owning the path, if any. `None` means source-owned.
111    pub tracker: Option<String>,
112}
113
114/// What running an action did.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum ActionOutcome {
117    /// Long-running action started under supervision.
118    Started {
119        pid: u32,
120        log: Utf8PathBuf,
121    },
122    Stopped(StopOutcome),
123    /// One-shot command finished with this exit code.
124    Ran {
125        code: i32,
126        log: Utf8PathBuf,
127        /// One warning per declared capture the command never emitted.
128        missing_captures: Vec<String>,
129    },
130}
131
132/// How a tracker's content landed in a workspace.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct TrackerBindOutcome {
135    pub name: String,
136    pub content_rev: Option<String>,
137    pub files: usize,
138    pub origin: BindOrigin,
139    /// Re-renders that failed after the lane head landed. See
140    /// [`RestoreReport::warnings`].
141    pub warnings: Vec<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum BindOrigin {
146    /// Projected from the lane head.
147    LaneHead,
148    /// Bound with no captured content yet.
149    Nothing,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct InstanceReport {
154    pub branch: BranchInstance,
155    pub workspace_exists: bool,
156    /// Live HEAD of the workspace clone, when it can be read.
157    pub live_rev: Option<String>,
158    pub trackers: Vec<TrackerReport>,
159    pub resources: Vec<ResourceReport>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ResourceReport {
164    pub name: String,
165    /// `running`, `stopped`, `ready`, `pending`, `failed`, or `—` (unbound).
166    pub state: String,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct TrackerReport {
171    pub name: String,
172    /// None when the tracker is defined but this instance has no binding.
173    pub content_rev: Option<String>,
174    /// The lane head/default, when one has been merged.
175    pub lane_head: Option<String>,
176}
177
178impl TrackerReport {
179    /// The lane has content this instance never had: pulling is safe advice.
180    pub fn never_pulled(&self) -> bool {
181        self.lane_head.is_some() && self.content_rev.is_none()
182    }
183
184    /// Bound content differs from the lane head. Without rev ancestry the
185    /// direction is unknowable — this instance may be ahead, behind, or
186    /// diverged — so callers must not advise one direction.
187    pub fn diverged(&self) -> bool {
188        self.content_rev.is_some() && self.lane_head.is_some() && self.content_rev != self.lane_head
189    }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct RemoveOutcome {
194    pub branch: BranchInstance,
195    pub archived_record: Utf8PathBuf,
196    /// What each resource's cleanup hook did, dependents first.
197    pub hooks: Vec<HookOutcome>,
198    /// The checkpoint history discarded, when removal was asked to purge it.
199    pub purged_checkpoints: Option<PurgedCheckpoints>,
200    /// How many checkpoints removal left behind instead — retained disk the
201    /// caller should be told about, since nothing reaches them any more.
202    pub kept_checkpoints: usize,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct ExportOutcome {
207    pub destination: Utf8PathBuf,
208    /// Branch name in the exported repository (the instance's source ref).
209    pub branch: String,
210    pub instance: String,
211    /// Workspace HEAD the export was taken from.
212    pub source_head: String,
213    /// The single commit the export produced.
214    pub commit: String,
215    pub plan: ExportPlan,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct CheckpointOutcome {
220    pub record: CheckpointRecord,
221    pub record_path: Utf8PathBuf,
222    pub warnings: Vec<String>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct UndoOutcome {
227    /// The checkpoint that was restored.
228    pub restored: CheckpointRecord,
229    /// Safety checkpoint taken first — restoring it again is redo.
230    pub safety: CheckpointRecord,
231    pub trackers: Vec<UndoTrackerOutcome>,
232    pub resources: Vec<UndoResourceOutcome>,
233    /// Written when any resource restore failed.
234    pub recovery_record: Option<Utf8PathBuf>,
235    pub warnings: Vec<String>,
236}
237
238impl UndoOutcome {
239    /// Resources whose restore failed.
240    ///
241    /// A restore command is not transactional: one that fails halfway (reset
242    /// the schema, then fail to load the rows) leaves its resource in neither
243    /// the pre-undo state nor the checkpoint state. newgit cannot fix that,
244    /// but it must not describe the instance as restored when it happened.
245    pub fn failed_resources(&self) -> Vec<&str> {
246        self.resources
247            .iter()
248            .filter(|resource| !resource.ok)
249            .map(|resource| resource.name.as_str())
250            .collect()
251    }
252
253    pub fn is_complete(&self) -> bool {
254        self.recovery_record.is_none() && self.failed_resources().is_empty()
255    }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct UndoTrackerOutcome {
260    pub name: String,
261    /// None means the checkpoint had no content: owned paths were cleared.
262    pub rev: Option<String>,
263    pub files: usize,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct UndoResourceOutcome {
268    /// What the restore did, for display: `none`, `recompute(prepare)`,
269    /// `command`, `external (no-op)`; `+ restarted` when a process came back.
270    pub action: String,
271    pub name: String,
272    pub ok: bool,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct CaptureReport {
277    pub rev: String,
278    pub files: usize,
279    /// False when the content was identical to the previous binding.
280    pub changed: bool,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct SeedReport {
285    pub rev: String,
286    pub files: usize,
287    /// False when the lane head already pointed at this content.
288    pub changed: bool,
289    /// Declared paths with nothing on disk in the store repo. Reported rather
290    /// than silently skipped: a lane seeded from half its paths is a bug you
291    /// want to hear about now, not at the first `spawn`.
292    pub missing_paths: Vec<Utf8PathBuf>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct RestoreReport {
297    pub rev: String,
298    pub files: usize,
299    /// Where the pre-restore content was saved, when it differed.
300    pub safety_rev: Option<String>,
301    /// Re-renders that failed after the content moved. Reported rather than
302    /// swallowed: the workspace is then running on committed defaults, which
303    /// is a different thing from what the instance was bound to.
304    pub warnings: Vec<String>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct MergeTrackerOutcome {
309    pub tracker: String,
310    pub rev: String,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct AddTrackerOutcome {
315    pub path: Utf8PathBuf,
316    pub ignored_patterns: Vec<String>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct TrackPathsOutcome {
321    pub path: Utf8PathBuf,
322    pub added_paths: Vec<Utf8PathBuf>,
323    pub ignored_patterns: Vec<String>,
324    /// At least one newly tracked path already has content in the store repo,
325    /// so the lane can be seeded from it without spawning anything.
326    pub seedable: bool,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct AddResourceOutcome {
331    pub path: Utf8PathBuf,
332    /// Companion definitions created because the template depends on them.
333    pub companions_created: Vec<Utf8PathBuf>,
334    /// Tracker lanes created because the template deposits into them.
335    pub trackers_created: Vec<Utf8PathBuf>,
336}
337
338impl BranchManager {
339    pub fn open(store: MetadataStore) -> Result<Self> {
340        store.ensure_initialized()?;
341        let config = store.load_config()?;
342        let source = GitSource::open(&store.paths().project_root, config.project.source)?;
343        let trackers = store.load_tracker_definitions()?;
344        let resources = store.load_resource_definitions()?;
345        let tracker_names: BTreeSet<String> = trackers.iter().map(|t| t.name.clone()).collect();
346        let (resource_order, graph_problems) = resolve_order(&resources, &tracker_names);
347        Ok(Self {
348            store,
349            config,
350            source,
351            trackers,
352            resources,
353            resource_order,
354            graph_problems,
355        })
356    }
357
358    /// Ways the resource graph is incomplete. An empty slice means it resolves.
359    pub fn graph_problems(&self) -> &[GraphProblem] {
360        &self.graph_problems
361    }
362
363    /// The gate for commands that act on the graph — `spawn`, `run`, `action`,
364    /// `checkpoint`, `undo`, `remove`. Commands that *build* the graph
365    /// (`tracker create`, `tracker track`, `resource add`) must not call this:
366    /// they are how an incomplete graph gets completed.
367    pub fn require_resolvable_graph(&self) -> Result<()> {
368        match self.graph_problems.first() {
369            Some(problem) => Err(problem.clone().into_error()),
370            None => Ok(()),
371        }
372    }
373
374    pub fn store(&self) -> &MetadataStore {
375        &self.store
376    }
377
378    /// `{{scripts}}` — the store's `.newgit/scripts/`.
379    ///
380    /// A resource definition is read from the store, but anything it shells
381    /// out to used to be read from the workspace, where it is subject to
382    /// source materialization: the two halves of one definition lived under
383    /// different rules, and only the TOML half was editable in place. A
384    /// script here resolves like the definition that calls it, so iterating on
385    /// a `prepare` does not mean committing every attempt.
386    fn scripts_dir(&self) -> &str {
387        self.store.paths().scripts.as_str()
388    }
389
390    pub fn tracker_definitions(&self) -> &[TrackerDefinition] {
391        &self.trackers
392    }
393
394    /// The tracker-path invariant, checked loudly: non-source tracker paths
395    /// must be gitignored unless deliberately dual-tracked with source.
396    pub fn gitignore_warnings(&self) -> Vec<String> {
397        let mut warnings = Vec::new();
398        for definition in &self.trackers {
399            for path in &definition.paths {
400                if let Ok(true) = self.source.is_tracked(path.as_str()) {
401                    warnings.push(format!(
402                        "tracker `{}` owns `{path}`, which Git also tracks (dual-tracked): \
403                         branch-local content will show as modifications and can be committed \
404                         into source history — untrack it with `git rm --cached {path}` unless \
405                         this is deliberate",
406                        definition.name
407                    ));
408                } else if let Ok(false) = self.source.is_ignored(path.as_str()) {
409                    warnings.push(format!(
410                        "tracker `{}` owns `{path}` but the store repo does not gitignore it; \
411                         agents may commit it into source history (fine only if deliberately \
412                         dual-tracked)",
413                        definition.name
414                    ));
415                }
416            }
417        }
418        warnings
419    }
420
421    pub fn spawn(&self, name: &str, from: Option<&str>) -> Result<SpawnOutcome> {
422        self.require_resolvable_graph()?;
423        validate_name(name)?;
424
425        let slug = branch_slug(name);
426        let record_path = self.store.branch_record_path(&slug);
427        if record_path.exists() {
428            return Err(NewgitError::BranchInstanceExists {
429                name: name.to_owned(),
430                path: record_path,
431            });
432        }
433
434        let created_source_branch = if self.source.branch_exists(name)? {
435            if let Some(base) = from {
436                return Err(NewgitError::Unsupported(format!(
437                    "source branch `{name}` already exists; `--from {base}` only applies when \
438                     creating a new branch"
439                )));
440            }
441            false
442        } else {
443            let base = from.unwrap_or("HEAD");
444            let base_rev = self.source.rev_parse(base)?;
445            self.source.create_branch(name, &base_rev)?;
446            true
447        };
448
449        let source_rev = self.source.rev_parse(&format!("refs/heads/{name}"))?;
450        let workspace_path = self
451            .config
452            .workspace_root(&self.store.paths().project_root)
453            .join(&slug);
454        let mut branch = BranchInstance::new(name, name, source_rev, workspace_path)?;
455
456        RealDirMaterializer.materialize(&self.source, &branch)?;
457
458        // Before any lane content lands, make the clone's Git ignore the
459        // paths those lanes own — otherwise projected content arrives as
460        // untracked files an agent can commit into source history.
461        let owned: Vec<Utf8PathBuf> = self
462            .trackers
463            .iter()
464            .flat_map(|definition| definition.paths.iter().cloned())
465            .collect();
466        exclude_tracker_paths(&branch.workspace_path, &owned)?;
467
468        let mut tracker_outcomes = Vec::new();
469        for definition in &self.trackers {
470            let outcome = self.bind_tracker(&mut branch, definition, false)?;
471            tracker_outcomes.push(outcome);
472        }
473
474        let resource_outcomes = self.bind_resources(&mut branch)?;
475
476        let record_path = self.store.create_branch_record(&branch)?;
477
478        Ok(SpawnOutcome {
479            branch,
480            record_path,
481            created_source_branch,
482            trackers: tracker_outcomes,
483            resources: resource_outcomes,
484        })
485    }
486
487    /// Allocate ports, render exports, and run `prepare` hooks in dependency
488    /// order. Prepare failures are loud but leave the instance spawned —
489    /// re-run with `newgit action <resource>.prepare`.
490    fn bind_resources(&self, branch: &mut BranchInstance) -> Result<Vec<ResourceBindOutcome>> {
491        let mut used = ports::used_ports(&self.store.load_branches()?);
492        let mut outcomes = Vec::new();
493
494        for name in &self.resource_order {
495            let definition = self.resource_definition(name)?;
496
497            let mut resolved_ports = BTreeMap::new();
498            for (port_name, request) in &definition.ports {
499                resolved_ports.insert(
500                    port_name.clone(),
501                    ports::allocate(request.start, &mut used)?,
502                );
503            }
504
505            let context = RenderContext {
506                branch_name: &branch.name,
507                branch_slug: &branch.slug,
508                workspace: branch.workspace_path.as_str(),
509                scripts: self.scripts_dir(),
510                ports: Some(&resolved_ports),
511                ..RenderContext::default()
512            };
513            let resolved_exports = definition
514                .exports
515                .iter()
516                .map(|(key, value)| (key.clone(), render(value, &context)))
517                .collect();
518
519            branch.resources.insert(
520                definition.name.clone(),
521                ResourceBinding {
522                    definition_rev: definition.definition_rev.clone(),
523                    resolved_ports: resolved_ports.clone(),
524                    resolved_exports,
525                    rendered: Vec::new(),
526                    status: ResourceStatus::Pending,
527                },
528            );
529
530            // Render before prepare, with ports allocated and every
531            // dependency's exports already bound: the file a tool reads its
532            // port from has to be right before the tool is started.
533            let (rendered, render_error) = match self.render_resource(branch, definition) {
534                Ok(rendered) => (rendered, None),
535                Err(error) => (Vec::new(), Some(error.to_string())),
536            };
537
538            let mut blocked_by = self.blocked_dependencies(branch, definition);
539            if render_error.is_some() {
540                if let Some(binding) = branch.resources.get_mut(&definition.name) {
541                    binding.status = ResourceStatus::Failed;
542                }
543                outcomes.push(ResourceBindOutcome {
544                    name: definition.name.clone(),
545                    ports: resolved_ports,
546                    status: ResourceStatus::Failed,
547                    prepare: None,
548                    blocked_by: std::mem::take(&mut blocked_by),
549                    captured: Vec::new(),
550                    missing_captures: Vec::new(),
551                    rendered: Vec::new(),
552                    render_error,
553                });
554                continue;
555            }
556
557            // Prepare runs with the bindings made so far, so dependents see
558            // their dependencies' exports. Failed dependencies block
559            // dependents; the instance still spawns so logs can be inspected.
560            let mut captured_names = Vec::new();
561            let mut missing_captures = Vec::new();
562            let (status, prepare) = if !blocked_by.is_empty() {
563                if let Some(binding) = branch.resources.get_mut(&definition.name) {
564                    binding.status = ResourceStatus::Blocked;
565                }
566                (ResourceStatus::Blocked, None)
567            } else {
568                match definition.actions.get("prepare") {
569                    Some(action) if action.command.is_some() && !action.long_running => {
570                        let log = self
571                            .store
572                            .action_log_path(&branch.slug, &format!("{}.prepare", definition.name));
573                        let (code, captured) =
574                            self.run_one_shot(branch, definition, action, &log)?;
575                        captured_names = captured.found.keys().cloned().collect();
576                        missing_captures = Self::missing_capture_warnings(
577                            &definition.name,
578                            "prepare",
579                            &captured,
580                            &log,
581                        );
582                        Self::apply_captures(branch, &definition.name, captured.found);
583                        let status = if code == 0 {
584                            ResourceStatus::Ready
585                        } else {
586                            ResourceStatus::Failed
587                        };
588                        if let Some(binding) = branch.resources.get_mut(&definition.name) {
589                            binding.status = status;
590                        }
591                        (status, Some((code == 0, log)))
592                    }
593                    _ => {
594                        if let Some(binding) = branch.resources.get_mut(&definition.name) {
595                            binding.status = ResourceStatus::Ready;
596                        }
597                        (ResourceStatus::Ready, None)
598                    }
599                }
600            };
601
602            outcomes.push(ResourceBindOutcome {
603                name: definition.name.clone(),
604                ports: resolved_ports,
605                status,
606                prepare,
607                blocked_by,
608                captured: captured_names,
609                missing_captures,
610                rendered,
611                render_error: None,
612            });
613        }
614        branch.updated_at = Utc::now();
615        Ok(outcomes)
616    }
617
618    /// Substitute this instance's values into the files a resource declares,
619    /// and record what was done on the binding.
620    ///
621    /// Committed content is the input, never the working file — so this is
622    /// idempotent and safe to re-run after an undo or a tracker pull.
623    fn render_resource(
624        &self,
625        branch: &mut BranchInstance,
626        definition: &ResourceDefinition,
627    ) -> Result<Vec<RenderOutcome>> {
628        if definition.render.is_empty() {
629            return Ok(Vec::new());
630        }
631
632        let context_ports = branch
633            .resources
634            .get(&definition.name)
635            .map(|binding| binding.resolved_ports.clone())
636            .unwrap_or_default();
637        // A template sees what a command in this instance would see: its own
638        // ports plus every export bound so far, in dependency order. Anything
639        // narrower and an Expo `.env` needing the database's URL would want a
640        // second mechanism.
641        let context_exports = self.bound_exports(branch);
642
643        let mut records = Vec::new();
644        let mut outcomes = Vec::new();
645        let mut source_owned = Vec::new();
646
647        for spec in &definition.render {
648            let owner = self.tracker_owning(&spec.path);
649            let committed = self.committed_content(branch, &spec.path, owner.as_deref())?;
650            let Some(committed) = committed else {
651                return Err(NewgitError::RenderPathNotCommitted {
652                    resource: definition.name.clone(),
653                    path: spec.path.clone(),
654                });
655            };
656
657            let context = RenderContext {
658                branch_name: &branch.name,
659                branch_slug: &branch.slug,
660                workspace: branch.workspace_path.as_str(),
661                scripts: self.scripts_dir(),
662                ports: Some(&context_ports),
663                exports: Some(&context_exports),
664                ..RenderContext::default()
665            };
666            let (contents, applied) = render::apply(&definition.name, spec, &committed, &context)?;
667
668            let target = branch.workspace_path.join(&spec.path);
669            if let Some(parent) = target.parent() {
670                crate::materializer::create_dir_all(parent)?;
671            }
672            std::fs::write(&target, contents)
673                .map_err(|source| NewgitError::io(target.clone(), source))?;
674
675            if owner.is_none() {
676                source_owned.push(spec.path.clone());
677            }
678            outcomes.push(RenderOutcome {
679                path: spec.path.clone(),
680                replacements: applied.len(),
681                tracker: owner.clone(),
682            });
683            records.push(RenderRecord {
684                path: spec.path.clone(),
685                tracker: owner,
686                applied,
687            });
688        }
689
690        // Source-owned targets only: tracker-owned paths are already in
691        // `.git/info/exclude`, and marking an untracked path skip-worktree is
692        // an error rather than a no-op.
693        GitSource::workspace_skip_worktree(&branch.workspace_path, &source_owned)?;
694
695        if let Some(binding) = branch.resources.get_mut(&definition.name) {
696            binding.rendered = records;
697        }
698        Ok(outcomes)
699    }
700
701    /// Put a tracker's rendered files back to their committed values for the
702    /// duration of a capture, so the lane records what every instance should
703    /// see rather than what this one is running on.
704    ///
705    /// The workspace file is written in place and restored afterwards rather
706    /// than captured from a copy, because capture walks the workspace: a
707    /// second tree would be a second thing to keep honest.
708    fn unrender_for_capture(
709        &self,
710        branch: &BranchInstance,
711        tracker: &TrackerDefinition,
712    ) -> Result<RenderedRestore> {
713        let mut restore = RenderedRestore::default();
714        for binding in branch.resources.values() {
715            for record in &binding.rendered {
716                if record.tracker.as_deref() != Some(tracker.name.as_str()) {
717                    continue;
718                }
719                let path = branch.workspace_path.join(&record.path);
720                let Ok(current) = std::fs::read_to_string(&path) else {
721                    continue;
722                };
723                let reversed = render::reverse(&current, &record.applied);
724                if reversed == current {
725                    continue;
726                }
727                std::fs::write(&path, &reversed)
728                    .map_err(|source| NewgitError::io(path.clone(), source))?;
729                restore.files.push((path, current));
730            }
731        }
732        Ok(restore)
733    }
734
735    /// Every export bound so far, in dependency order — dependents win, the
736    /// same layering [`Self::assemble_env`] applies.
737    fn bound_exports(&self, branch: &BranchInstance) -> BTreeMap<String, String> {
738        let mut exports = BTreeMap::new();
739        for name in &self.resource_order {
740            if let Some(binding) = branch.resources.get(name) {
741                for (key, value) in &binding.resolved_exports {
742                    exports.insert(key.clone(), value.clone());
743                }
744            }
745        }
746        exports
747    }
748
749    /// The tracker owning a path, if any. Decides where committed content
750    /// comes from and whether `capture` has to reverse the render.
751    fn tracker_owning(&self, path: &Utf8Path) -> Option<String> {
752        self.trackers
753            .iter()
754            .find(|definition| {
755                definition
756                    .paths
757                    .iter()
758                    .any(|owned| path == owned || path.starts_with(owned))
759            })
760            .map(|definition| definition.name.clone())
761    }
762
763    /// What a render substitutes into: the bound lane rev for a tracker-owned
764    /// path, HEAD for a source-owned one. Never the working file.
765    fn committed_content(
766        &self,
767        branch: &BranchInstance,
768        path: &Utf8Path,
769        tracker: Option<&str>,
770    ) -> Result<Option<String>> {
771        let Some(tracker) = tracker else {
772            return GitSource::workspace_show_head(&branch.workspace_path, path);
773        };
774        let Some(rev) = branch
775            .trackers
776            .get(tracker)
777            .and_then(|binding| binding.content_rev.clone())
778        else {
779            return Ok(None);
780        };
781        let source = self.lane(tracker).rev_path(&rev).join(path);
782        match std::fs::read_to_string(&source) {
783            Ok(contents) => Ok(Some(contents)),
784            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
785            Err(error) => Err(NewgitError::io(source, error)),
786        }
787    }
788
789    /// Rendered paths across every resource — what export must take from HEAD
790    /// and what a checkpoint's dirty commit must leave alone.
791    fn rendered_source_paths(&self, branch: &BranchInstance) -> Vec<Utf8PathBuf> {
792        branch
793            .resources
794            .values()
795            .flat_map(|binding| binding.rendered.iter())
796            .filter(|record| record.tracker.is_none())
797            .map(|record| record.path.clone())
798            .collect()
799    }
800
801    /// Re-render every resource's targets, in dependency order.
802    ///
803    /// Undo restores source and tracker content underneath the rendered
804    /// files, so the values have to be put back. That costs nothing, because
805    /// a render is a pure function of committed content and the binding
806    /// record — both of which undo has just settled.
807    /// Rendered files whose content on disk is not what this instance's
808    /// render produces — that is, hand edits a re-render will discard.
809    ///
810    /// A render is a pure function of committed content and the binding
811    /// record, so the expected bytes are recomputable at any time. Comparing
812    /// against them turns the generic caveat "edits to a rendered file do not
813    /// survive" into the specific one: *this file has changes, and this is
814    /// the moment they are about to go.* It is silent when there is nothing
815    /// to say, which a bind-time warning cannot be — at bind the edit does
816    /// not exist yet.
817    ///
818    /// A recompute that fails (the committed content moved under the
819    /// definition) is not drift and is not reported here; the re-render that
820    /// follows reports it.
821    fn render_drift(&self, branch: &BranchInstance) -> Vec<String> {
822        let mut drifted = Vec::new();
823        for (resource, binding) in &branch.resources {
824            for record in &binding.rendered {
825                let Ok(Some(committed)) =
826                    self.committed_content(branch, &record.path, record.tracker.as_deref())
827                else {
828                    continue;
829                };
830                let Ok(expected) =
831                    render::substitute(resource, &record.path, &committed, &record.applied)
832                else {
833                    continue;
834                };
835                let Ok(actual) = std::fs::read_to_string(branch.workspace_path.join(&record.path))
836                else {
837                    continue;
838                };
839                if actual != expected {
840                    drifted.push(format!(
841                        "`{}` has changes that render will discard: it is rendered by resource \
842                         `{resource}`, so newgit rewrites it from committed content and this \
843                         instance's values ({} line(s) differ). Move the edit into the \
844                         committed file in the store repo to keep it.",
845                        record.path,
846                        differing_lines(&expected, &actual)
847                    ));
848                }
849            }
850        }
851        drifted
852    }
853
854    fn rerender_all(&self, branch: &mut BranchInstance) -> Vec<String> {
855        // Checked before the re-render, not after: afterwards the edit is
856        // already gone and there is nothing left to name.
857        let mut warnings = self.render_drift(branch);
858        for name in self.resource_order.clone() {
859            let Ok(definition) = self.resource_definition(&name) else {
860                continue;
861            };
862            if definition.render.is_empty() {
863                continue;
864            }
865            let definition = definition.clone();
866            if let Err(error) = self.render_resource(branch, &definition) {
867                warnings.push(format!("re-render for resource `{name}` failed: {error}"));
868            }
869        }
870        warnings
871    }
872
873    /// Run `<resource>.<action>` for an instance.
874    pub fn run_action(&self, instance: &str, spec: &str) -> Result<ActionOutcome> {
875        self.require_resolvable_graph()?;
876        let (resource_name, action_name) = spec.split_once('.').ok_or_else(|| {
877            NewgitError::Unsupported(format!("`{spec}` is not of the form <resource>.<action>"))
878        })?;
879        let mut branch = self.store.find_branch(instance)?;
880        self.require_workspace(&branch)?;
881        let definition = self.resource_definition(resource_name)?;
882        let action =
883            definition
884                .actions
885                .get(action_name)
886                .ok_or_else(|| NewgitError::UnknownAction {
887                    resource: resource_name.to_owned(),
888                    action: action_name.to_owned(),
889                })?;
890        let supervisor = self.supervisor(&branch);
891
892        // Signal-only action (e.g. stop): signal the supervised process.
893        if action.command.is_none() {
894            let signal = action
895                .signal
896                .clone()
897                .unwrap_or_else(|| definition.stop_signal());
898            return Ok(ActionOutcome::Stopped(
899                supervisor.stop(&definition.name, &signal)?,
900            ));
901        }
902
903        let blocked_by = self.blocked_dependencies(&branch, definition);
904        if !blocked_by.is_empty() {
905            if let Some(binding) = branch.resources.get_mut(&definition.name) {
906                binding.status = ResourceStatus::Blocked;
907                branch.updated_at = Utc::now();
908                self.store.save_branch_record(&branch)?;
909            }
910            return Err(NewgitError::Unsupported(format!(
911                "resource `{resource_name}` is blocked by failed dependency/dependencies: {}",
912                blocked_by.join(", ")
913            )));
914        }
915
916        let log = self
917            .store
918            .action_log_path(&branch.slug, &format!("{}.{action_name}", definition.name));
919
920        if action.long_running {
921            let command = self.rendered_command(&branch, definition, action)?;
922            let env = self.assemble_env(&branch)?;
923            let pid = supervisor.start(
924                &definition.name,
925                &command,
926                &branch.workspace_path,
927                &env,
928                &log,
929            )?;
930            return Ok(ActionOutcome::Started { pid, log });
931        }
932
933        let (code, captured) = self.run_one_shot(&branch, definition, action, &log)?;
934        let missing_captures =
935            Self::missing_capture_warnings(&definition.name, action_name, &captured, &log);
936        let mut dirty = Self::apply_captures(&mut branch, &definition.name, captured.found);
937        if action_name == "prepare"
938            && let Some(binding) = branch.resources.get_mut(&definition.name)
939        {
940            binding.status = if code == 0 {
941                ResourceStatus::Ready
942            } else {
943                ResourceStatus::Failed
944            };
945            dirty = true;
946        }
947        if dirty {
948            branch.updated_at = Utc::now();
949            self.store.save_branch_record(&branch)?;
950        }
951        Ok(ActionOutcome::Ran {
952            code,
953            log,
954            missing_captures,
955        })
956    }
957
958    /// Run an arbitrary command inside the instance with the full export
959    /// environment loaded. Returns the exit code.
960    pub fn run_command(
961        &self,
962        instance: &str,
963        command_line: &[String],
964    ) -> Result<(i32, Utf8PathBuf)> {
965        let branch = self.store.find_branch(instance)?;
966        self.require_workspace(&branch)?;
967        let env = self.assemble_env(&branch)?;
968        let log = self.store.action_log_path(&branch.slug, "run");
969        let code = run_foreground(command_line, &branch.workspace_path, &env, &log)?;
970        Ok((code, log))
971    }
972
973    /// Run a one-shot action, returning its exit code and whatever values it
974    /// declared in `captures`. An action with captures runs captured (its
975    /// output reaches the log but not the terminal), because newgit has to
976    /// read stdout to find the handle the command just minted.
977    fn run_one_shot(
978        &self,
979        branch: &BranchInstance,
980        definition: &ResourceDefinition,
981        action: &crate::resource::ActionSpec,
982        log: &Utf8Path,
983    ) -> Result<(i32, Captures)> {
984        let command = self.rendered_command(branch, definition, action)?;
985        let env = self.assemble_env(branch)?;
986
987        if action.captures.is_empty() {
988            let code = run_foreground(
989                &["sh".to_owned(), "-c".to_owned(), command],
990                &branch.workspace_path,
991                &env,
992                log,
993            )?;
994            return Ok((code, Captures::default()));
995        }
996
997        let (code, stdout) = run_captured(&command, &branch.workspace_path, &env, log)?;
998        Ok((code, parse_captures(&stdout, &action.captures)))
999    }
1000
1001    /// One warning per declared capture the command never emitted. The log
1002    /// path is included because the answer is nearly always in the command's
1003    /// own output — typically its stdout carrying something other than the
1004    /// captures.
1005    fn missing_capture_warnings(
1006        resource: &str,
1007        action: &str,
1008        captures: &Captures,
1009        log: &Utf8Path,
1010    ) -> Vec<String> {
1011        captures
1012            .missing
1013            .iter()
1014            .map(|name| {
1015                format!(
1016                    "resource `{resource}` declared capture `{name}` on `{action}`, not found \
1017                     in stdout (log: {log}); when `captures` is set, stdout belongs to newgit — \
1018                     send everything else to stderr"
1019                )
1020            })
1021            .collect()
1022    }
1023
1024    /// Merge values an action captured into the resource's binding exports,
1025    /// so later commands, hooks, and `newgit run` all see the handle. The
1026    /// binding record is the single source of truth for a resource instance,
1027    /// including the parts another system named.
1028    fn apply_captures(
1029        branch: &mut BranchInstance,
1030        resource: &str,
1031        captured: BTreeMap<String, String>,
1032    ) -> bool {
1033        if captured.is_empty() {
1034            return false;
1035        }
1036        let Some(binding) = branch.resources.get_mut(resource) else {
1037            return false;
1038        };
1039        binding.resolved_exports.extend(captured);
1040        true
1041    }
1042
1043    fn rendered_command(
1044        &self,
1045        branch: &BranchInstance,
1046        definition: &ResourceDefinition,
1047        action: &crate::resource::ActionSpec,
1048    ) -> Result<String> {
1049        let command = action.command.clone().ok_or_else(|| {
1050            NewgitError::Unsupported(format!(
1051                "resource `{}` action has no command",
1052                definition.name
1053            ))
1054        })?;
1055        let context = RenderContext {
1056            branch_name: &branch.name,
1057            branch_slug: &branch.slug,
1058            workspace: branch.workspace_path.as_str(),
1059            scripts: self.scripts_dir(),
1060            ports: branch
1061                .resources
1062                .get(&definition.name)
1063                .map(|binding| &binding.resolved_ports),
1064            ..RenderContext::default()
1065        };
1066        Ok(render(&command, &context))
1067    }
1068
1069    /// The layered environment `newgit run` and actions see. Later layers
1070    /// win: resource exports in dependency order → port env vars → newgit
1071    /// context vars. Trackers own content; command environment wiring lives
1072    /// outside the tracker primitive.
1073    pub fn assemble_env(&self, branch: &BranchInstance) -> Result<Vec<(String, String)>> {
1074        // Layering depends on dependency order, so the order has to be real.
1075        self.require_resolvable_graph()?;
1076        let mut env: BTreeMap<String, String> = BTreeMap::new();
1077
1078        // Layer 1: resource exports, dependency order (dependents win).
1079        for name in &self.resource_order {
1080            if let Some(binding) = branch.resources.get(name) {
1081                for (key, value) in &binding.resolved_exports {
1082                    env.insert(key.clone(), value.clone());
1083                }
1084            }
1085        }
1086
1087        // Layer 2: port env vars.
1088        for name in &self.resource_order {
1089            let Some(binding) = branch.resources.get(name) else {
1090                continue;
1091            };
1092            let Ok(definition) = self.resource_definition(name) else {
1093                continue;
1094            };
1095            for (port_name, request) in &definition.ports {
1096                if let (Some(env_name), Some(port)) =
1097                    (&request.env, binding.resolved_ports.get(port_name))
1098                {
1099                    env.insert(env_name.clone(), port.to_string());
1100                }
1101            }
1102        }
1103
1104        // Layer 3: context vars.
1105        env.insert("NEWGIT_BRANCH".to_owned(), branch.name.clone());
1106        env.insert(
1107            "NEWGIT_WORKSPACE".to_owned(),
1108            branch.workspace_path.to_string(),
1109        );
1110
1111        Ok(env.into_iter().collect())
1112    }
1113
1114    pub fn add_resource(&self, name: &str, template_name: &str) -> Result<AddResourceOutcome> {
1115        validate_name(name)?;
1116        let template = resource_template(template_name)
1117            .ok_or_else(|| NewgitError::UnknownTemplate(template_name.to_owned()))?;
1118        let path = self.store.write_resource_file(name, template.contents)?;
1119
1120        // Companions the template depends on, created only when absent so an
1121        // existing definition is never overwritten.
1122        let mut companions_created = Vec::new();
1123        for companion in template.companions {
1124            let companion_path = self
1125                .store
1126                .paths()
1127                .resources
1128                .join(format!("{}.toml", companion.name));
1129            if !companion_path.exists() {
1130                companions_created.push(
1131                    self.store
1132                        .write_resource_file(companion.name, companion.contents)?,
1133                );
1134            }
1135        }
1136
1137        // Lanes the template deposits into. A checkpoint whose `into_tracker`
1138        // names a tracker that does not exist fails at checkpoint time, so a
1139        // template that deposits has to bring its lane with it.
1140        let mut trackers_created = Vec::new();
1141        for companion in template.companion_trackers {
1142            let tracker_path = self
1143                .store
1144                .paths()
1145                .trackers
1146                .join(format!("{}.toml", companion.name));
1147            if !tracker_path.exists() {
1148                trackers_created.push(
1149                    self.create_tracker(
1150                        companion.name,
1151                        companion.audience,
1152                        Storage::Local,
1153                        companion.merge_with_source,
1154                    )?
1155                    .path,
1156                );
1157            }
1158        }
1159
1160        Ok(AddResourceOutcome {
1161            path,
1162            companions_created,
1163            trackers_created,
1164        })
1165    }
1166
1167    pub fn resource_definitions(&self) -> &[ResourceDefinition] {
1168        &self.resources
1169    }
1170
1171    fn resource_definition(&self, name: &str) -> Result<&ResourceDefinition> {
1172        self.resources
1173            .iter()
1174            .find(|definition| definition.name == name)
1175            .ok_or_else(|| NewgitError::UnknownResource(name.to_owned()))
1176    }
1177
1178    fn supervisor(&self, branch: &BranchInstance) -> Supervisor {
1179        Supervisor::new(self.store.instance_state_dir(&branch.slug))
1180    }
1181
1182    fn blocked_dependencies(
1183        &self,
1184        branch: &BranchInstance,
1185        definition: &ResourceDefinition,
1186    ) -> Vec<String> {
1187        definition
1188            .depends_on
1189            .iter()
1190            .filter_map(|dependency| {
1191                branch.resources.get(dependency).and_then(|binding| {
1192                    (binding.status != ResourceStatus::Ready).then(|| dependency.clone())
1193                })
1194            })
1195            .collect()
1196    }
1197
1198    /// Bind a tracker into an instance workspace. If the lane has captured
1199    /// content, project the lane head; otherwise the tracker starts empty.
1200    /// `refresh` allows rebinding an already-bound tracker.
1201    fn bind_tracker(
1202        &self,
1203        branch: &mut BranchInstance,
1204        definition: &TrackerDefinition,
1205        refresh: bool,
1206    ) -> Result<TrackerBindOutcome> {
1207        let lane = self.lane(&definition.name);
1208        let workspace = branch.workspace_path.clone();
1209
1210        // Safety net when re-materializing over existing content.
1211        if refresh && !definition.paths.is_empty() {
1212            let existing = collect_owned_files(&workspace, definition)?;
1213            if !existing.is_empty() {
1214                lane.capture(&workspace, definition)?;
1215            }
1216        }
1217
1218        let (origin, content_rev, files) = match lane.latest() {
1219            Some(rev) => {
1220                let files = lane.restore(&workspace, definition, &rev)?;
1221                (BindOrigin::LaneHead, Some(rev), files)
1222            }
1223            None => (BindOrigin::Nothing, None, 0),
1224        };
1225
1226        branch.trackers.insert(
1227            definition.name.clone(),
1228            TrackerBinding {
1229                definition_rev: definition.definition_rev.clone(),
1230                content_rev: content_rev.clone(),
1231            },
1232        );
1233        branch.updated_at = Utc::now();
1234
1235        Ok(TrackerBindOutcome {
1236            warnings: Vec::new(),
1237            name: definition.name.clone(),
1238            content_rev,
1239            files,
1240            origin,
1241        })
1242    }
1243
1244    pub fn capture_tracker(&self, instance: &str, tracker: &str) -> Result<CaptureReport> {
1245        let mut branch = self.store.find_branch(instance)?;
1246        let definition = self.definition(tracker)?;
1247        self.require_workspace(&branch)?;
1248
1249        // A lane is shared by every instance, so this instance's rendered
1250        // values must not enter it — but the file cannot simply be skipped
1251        // either, or a key added beside them would never reach the lane.
1252        // Reversing the substitution keeps the edits and drops the values.
1253        let reversed = self.unrender_for_capture(&branch, definition)?;
1254
1255        let lane = self.lane(&definition.name);
1256        let capture = lane.capture(&branch.workspace_path, definition);
1257        reversed.restore()?;
1258        let capture = capture?;
1259
1260        let previous = branch
1261            .trackers
1262            .get(&definition.name)
1263            .and_then(|binding| binding.content_rev.clone());
1264        let changed = previous.as_deref() != Some(capture.rev.as_str());
1265        branch.trackers.insert(
1266            definition.name.clone(),
1267            TrackerBinding {
1268                definition_rev: definition.definition_rev.clone(),
1269                content_rev: Some(capture.rev.clone()),
1270            },
1271        );
1272        branch.updated_at = Utc::now();
1273        self.store.save_branch_record(&branch)?;
1274
1275        Ok(CaptureReport {
1276            rev: capture.rev,
1277            files: capture.files,
1278            changed,
1279        })
1280    }
1281
1282    /// Seed a lane from the store repo's working tree and make it the lane
1283    /// head.
1284    ///
1285    /// A lane starts empty and [`Self::capture_tracker`] reads from an instance
1286    /// workspace, so the first instance of an env-carrying tracker is
1287    /// guaranteed to come up without its files. In a project adopting newgit
1288    /// that content already exists in the store repo at the same relative
1289    /// paths, so read it from there rather than round-tripping it through an
1290    /// instance. Seeding sets the lane head directly: there is no binding
1291    /// record to promote from, and the point is that the next `spawn` works.
1292    pub fn seed_tracker_from_store(&self, tracker: &str) -> Result<SeedReport> {
1293        // `definition_or_load`, like `track_paths`: seeding usually follows
1294        // `tracker track` closely enough to beat a reopened manager.
1295        let definition = &self.definition_or_load(tracker)?;
1296        if definition.paths.is_empty() {
1297            return Err(NewgitError::TrackerHasNoPaths(definition.name.clone()));
1298        }
1299
1300        let root = self.store.paths().project_root.clone();
1301        let (present, missing): (Vec<_>, Vec<_>) = definition
1302            .paths
1303            .iter()
1304            .cloned()
1305            .partition(|path| root.join(path).exists());
1306        if present.is_empty() {
1307            return Err(NewgitError::NothingToSeed {
1308                tracker: definition.name.clone(),
1309                paths: missing
1310                    .iter()
1311                    .map(|path| path.as_str())
1312                    .collect::<Vec<_>>()
1313                    .join(", "),
1314            });
1315        }
1316
1317        let lane = self.lane(&definition.name);
1318        let capture = lane.capture(&root, definition)?;
1319        let changed = lane.latest().as_deref() != Some(capture.rev.as_str());
1320        lane.set_latest(&capture.rev)?;
1321
1322        Ok(SeedReport {
1323            rev: capture.rev,
1324            files: capture.files,
1325            changed,
1326            missing_paths: missing,
1327        })
1328    }
1329
1330    pub fn checkout_tracker(
1331        &self,
1332        instance: &str,
1333        tracker: &str,
1334        rev: Option<&str>,
1335    ) -> Result<RestoreReport> {
1336        let mut branch = self.store.find_branch(instance)?;
1337        let definition = self.definition(tracker)?;
1338        self.require_workspace(&branch)?;
1339        let lane = self.lane(&definition.name);
1340
1341        let target_rev = match rev {
1342            Some(rev) => rev.to_owned(),
1343            None => branch
1344                .trackers
1345                .get(&definition.name)
1346                .and_then(|binding| binding.content_rev.clone())
1347                .ok_or_else(|| {
1348                    NewgitError::Unsupported(format!(
1349                        "tracker `{}` has no bound content for `{}`; pass --rev",
1350                        definition.name, branch.name
1351                    ))
1352                })?,
1353        };
1354        if !lane.has_rev(&target_rev) {
1355            return Err(NewgitError::NoSnapshot {
1356                tracker: definition.name.clone(),
1357                rev: target_rev,
1358            });
1359        }
1360
1361        // Restoring never loses state: current content is captured first —
1362        // with renders reversed, so the safety rev is a lane rev like any
1363        // other rather than one instance's ports.
1364        let reversed = self.unrender_for_capture(&branch, definition)?;
1365        let safety = lane.capture(&branch.workspace_path, definition);
1366        reversed.restore()?;
1367        let safety = safety?;
1368        let safety_rev = (safety.rev != target_rev).then_some(safety.rev);
1369
1370        let files = lane.restore(&branch.workspace_path, definition, &target_rev)?;
1371
1372        branch.trackers.insert(
1373            definition.name.clone(),
1374            TrackerBinding {
1375                definition_rev: definition.definition_rev.clone(),
1376                content_rev: Some(target_rev.clone()),
1377            },
1378        );
1379        // The checked-out content is committed content; this instance's
1380        // values go back on top of it.
1381        let warnings = self.rerender_all(&mut branch);
1382        branch.updated_at = Utc::now();
1383        self.store.save_branch_record(&branch)?;
1384
1385        Ok(RestoreReport {
1386            rev: target_rev,
1387            files,
1388            safety_rev,
1389            warnings,
1390        })
1391    }
1392
1393    /// Pull a tracker's lane head into an existing instance.
1394    pub fn pull_tracker(&self, instance: &str, tracker: &str) -> Result<TrackerBindOutcome> {
1395        let mut branch = self.store.find_branch(instance)?;
1396        let definition = self.definition(tracker)?;
1397        self.require_workspace(&branch)?;
1398
1399        if self.lane(&definition.name).latest().is_none() {
1400            return Err(NewgitError::Unsupported(format!(
1401                "tracker `{}` has no merged content to pull",
1402                definition.name
1403            )));
1404        }
1405
1406        let mut outcome = self.bind_tracker(&mut branch, definition, true)?;
1407        outcome.warnings = self.rerender_all(&mut branch);
1408        self.store.save_branch_record(&branch)?;
1409        Ok(outcome)
1410    }
1411
1412    /// Promote this branch instance's bound tracker revision to the lane head.
1413    pub fn merge_tracker(&self, instance: &str, tracker: &str) -> Result<MergeTrackerOutcome> {
1414        let branch = self.store.find_branch(instance)?;
1415        let definition = self.definition(tracker)?;
1416        let rev = branch
1417            .trackers
1418            .get(&definition.name)
1419            .and_then(|binding| binding.content_rev.clone())
1420            .ok_or_else(|| {
1421                NewgitError::Unsupported(format!(
1422                    "tracker `{}` has no captured content for `{}`; run `newgit tracker capture {}` first",
1423                    definition.name, branch.name, definition.name
1424                ))
1425            })?;
1426        let lane = self.lane(&definition.name);
1427        if !lane.has_rev(&rev) {
1428            return Err(NewgitError::NoSnapshot {
1429                tracker: definition.name.clone(),
1430                rev,
1431            });
1432        }
1433        lane.set_latest(&rev)?;
1434        Ok(MergeTrackerOutcome {
1435            tracker: definition.name.clone(),
1436            rev,
1437        })
1438    }
1439
1440    pub fn create_tracker(
1441        &self,
1442        name: &str,
1443        audience: &str,
1444        storage: Storage,
1445        merge_with_source: bool,
1446    ) -> Result<AddTrackerOutcome> {
1447        validate_name(name)?;
1448        let definition = TrackerDefinition::new(
1449            name,
1450            audience.to_owned(),
1451            storage,
1452            merge_with_source,
1453            Vec::new(),
1454        )?;
1455        let path = self.store.create_tracker_definition(&definition)?;
1456        Ok(AddTrackerOutcome {
1457            path,
1458            ignored_patterns: Vec::new(),
1459        })
1460    }
1461
1462    pub fn track_paths(&self, tracker: &str, paths: &[Utf8PathBuf]) -> Result<TrackPathsOutcome> {
1463        let definition = self.definition_or_load(tracker)?;
1464        let updated = definition.with_added_paths(paths)?;
1465        validate_disjoint_with_replacement(&self.trackers, &updated)?;
1466        let path = self.store.save_tracker_definition(&updated)?;
1467
1468        let mut patterns = Vec::new();
1469        for owned in paths {
1470            if !self.source.is_ignored(owned.as_str())? {
1471                patterns.push(format!("/{owned}"));
1472            }
1473        }
1474        self.store.append_gitignore(tracker, &patterns)?;
1475
1476        // If the content is already sitting in the store repo, seeding the lane
1477        // from it is the next thing you want; the caller says so.
1478        let root = &self.store.paths().project_root;
1479        let seedable = paths.iter().any(|owned| root.join(owned).exists());
1480
1481        Ok(TrackPathsOutcome {
1482            path,
1483            added_paths: paths.to_vec(),
1484            ignored_patterns: patterns,
1485            seedable,
1486        })
1487    }
1488
1489    pub fn statuses(&self) -> Result<Vec<InstanceReport>> {
1490        let lane_heads: Vec<(String, Option<String>)> = self
1491            .trackers
1492            .iter()
1493            .map(|definition| {
1494                (
1495                    definition.name.clone(),
1496                    self.lane(&definition.name).latest(),
1497                )
1498            })
1499            .collect();
1500
1501        self.store
1502            .load_branches()?
1503            .into_iter()
1504            .map(|branch| {
1505                let workspace_exists = branch.workspace_path.is_dir();
1506                let live_rev = workspace_exists
1507                    .then(|| GitSource::workspace_short_head(&branch.workspace_path).ok())
1508                    .flatten();
1509                let trackers = self
1510                    .trackers
1511                    .iter()
1512                    .map(|definition| {
1513                        let content_rev = branch
1514                            .trackers
1515                            .get(&definition.name)
1516                            .and_then(|binding| binding.content_rev.clone());
1517                        let head = lane_heads
1518                            .iter()
1519                            .find(|(name, _)| name == &definition.name)
1520                            .and_then(|(_, head)| head.clone());
1521                        TrackerReport {
1522                            name: definition.name.clone(),
1523                            content_rev,
1524                            lane_head: head,
1525                        }
1526                    })
1527                    .collect();
1528                let supervisor = self.supervisor(&branch);
1529                let resources = self
1530                    .resources
1531                    .iter()
1532                    .map(|definition| {
1533                        let state = match branch.resources.get(&definition.name) {
1534                            None => "—".to_owned(),
1535                            Some(binding) => {
1536                                if supervisor.running_pid(&definition.name).is_some() {
1537                                    "running".to_owned()
1538                                } else if definition.has_long_running_action()
1539                                    && binding.status == ResourceStatus::Ready
1540                                {
1541                                    "stopped".to_owned()
1542                                } else {
1543                                    match binding.status {
1544                                        ResourceStatus::Pending => "pending".to_owned(),
1545                                        ResourceStatus::Ready => "ready".to_owned(),
1546                                        ResourceStatus::Failed => "failed".to_owned(),
1547                                        ResourceStatus::Blocked => "blocked".to_owned(),
1548                                    }
1549                                }
1550                            }
1551                        };
1552                        ResourceReport {
1553                            name: definition.name.clone(),
1554                            state,
1555                        }
1556                    })
1557                    .collect();
1558                Ok(InstanceReport {
1559                    branch,
1560                    workspace_exists,
1561                    live_rev,
1562                    trackers,
1563                    resources,
1564                })
1565            })
1566            .collect()
1567    }
1568
1569    /// Deletes the workspace (plain `rm -rf`; clones have no registration)
1570    /// and archives the binding record. The source branch in the store is
1571    /// kept — removal disposes of the workspace, not the history.
1572    ///
1573    /// Resource cleanup hooks run first, dependents before dependencies and
1574    /// while the workspace still exists. Without that, a resource newgit
1575    /// does not own — a cloud preview, a database — would outlive every
1576    /// trace of the instance that asked for it.
1577    /// Deliberately not gated on [`Self::require_resolvable_graph`]: teardown
1578    /// must stay reachable from a broken graph, and cleanup hooks run for every
1579    /// bound resource regardless of how they are ordered relative to each other.
1580    ///
1581    /// Checkpoints outlive removal by default — they pin the lane revs their
1582    /// undo would need, and the instance may be re-created. `checkpoints =
1583    /// Purge` says that undo will never be wanted, and drops them so the next
1584    /// `cleanup` can reclaim what they held.
1585    pub fn remove(
1586        &self,
1587        name: &str,
1588        cwd: &Utf8Path,
1589        checkpoints: ArchivedCheckpoints,
1590    ) -> Result<RemoveOutcome> {
1591        let branch = self.store.find_branch(name)?;
1592
1593        if cwd.starts_with(&branch.workspace_path) {
1594            return Err(NewgitError::Unsupported(format!(
1595                "the current directory is inside the workspace of `{}`; step out of it before \
1596                 removing",
1597                branch.name
1598            )));
1599        }
1600
1601        // Stop anything still running before the workspace disappears.
1602        let supervisor = self.supervisor(&branch);
1603        for definition in &self.resources {
1604            if supervisor.running_pid(&definition.name).is_some() {
1605                supervisor.stop(&definition.name, &definition.stop_signal())?;
1606            }
1607        }
1608
1609        let hooks = self.run_cleanup_hooks(&branch, false)?;
1610
1611        let state_dir = self.store.instance_state_dir(&branch.slug);
1612        if state_dir.exists() {
1613            std::fs::remove_dir_all(&state_dir)
1614                .map_err(|source| NewgitError::io(state_dir, source))?;
1615        }
1616
1617        RealDirMaterializer.remove(&branch)?;
1618        let archived_record = self.store.archive_branch_record(&branch)?;
1619        // Either way, count what the instance leaves behind: checkpoints kept
1620        // for an unreachable instance are disk nobody will reclaim by accident.
1621        let keeping = checkpoints == ArchivedCheckpoints::Keep;
1622        let found = self.purge_checkpoints(&branch.slug, keeping)?;
1623        let (purged_checkpoints, kept_checkpoints) = match (keeping, found) {
1624            (true, found) => (None, found.map_or(0, |plan| plan.checkpoints)),
1625            (false, purged) => (purged, 0),
1626        };
1627
1628        Ok(RemoveOutcome {
1629            branch,
1630            archived_record,
1631            hooks,
1632            purged_checkpoints,
1633            kept_checkpoints,
1634        })
1635    }
1636
1637    /// Drop one instance's checkpoint log and the store refs it held.
1638    ///
1639    /// This is the only operation that can break an undo, so nothing calls it
1640    /// implicitly: a checkpoint is what `newgit undo` restores, and the lane
1641    /// revs it names are pinned for exactly that reason. Once the binding
1642    /// record is archived the undo is unreachable anyway — but "unreachable"
1643    /// is still the user's call to make, not a garbage collector's.
1644    ///
1645    /// Returns `None` when the instance has no checkpoints at all.
1646    fn purge_checkpoints(&self, slug: &str, dry_run: bool) -> Result<Option<PurgedCheckpoints>> {
1647        let dir = self.store.checkpoint_dir(slug);
1648        if !dir.is_dir() {
1649            return Ok(None);
1650        }
1651        let checkpoints = CheckpointLog::new(dir.clone(), slug).list()?.len();
1652        let refs = self
1653            .source
1654            .refs_under(&format!("refs/newgit/checkpoints/{slug}"))?;
1655
1656        if !dry_run {
1657            for name in &refs {
1658                self.source.delete_ref(name)?;
1659            }
1660            std::fs::remove_dir_all(&dir).map_err(|source| NewgitError::io(&dir, source))?;
1661        }
1662
1663        Ok(Some(PurgedCheckpoints {
1664            slug: slug.to_owned(),
1665            checkpoints,
1666            source_refs: refs.len(),
1667            dir,
1668        }))
1669    }
1670
1671    /// Run each bound resource's `[cleanup] command`, dependents before
1672    /// dependencies. Ownership decides whether a hook may run at all —
1673    /// `project` and `user` resources are shared beyond this instance, so
1674    /// per-branch teardown leaves them alone even when they define a hook.
1675    fn run_cleanup_hooks(
1676        &self,
1677        branch: &BranchInstance,
1678        dry_run: bool,
1679    ) -> Result<Vec<HookOutcome>> {
1680        // The workspace is usually still here; when cleanup is finishing an
1681        // instance whose workspace is already gone, hooks run from the store
1682        // root so an external teardown can still reach its own API.
1683        let cwd = if branch.workspace_path.is_dir() {
1684            branch.workspace_path.clone()
1685        } else {
1686            self.store.paths().project_root.clone()
1687        };
1688
1689        let mut outcomes = Vec::new();
1690        for name in self.resource_order.iter().rev() {
1691            if !branch.resources.contains_key(name) {
1692                continue;
1693            }
1694            let definition = self.resource_definition(name)?;
1695            let ownership = definition.ownership;
1696
1697            if !may_tear_down(ownership) {
1698                outcomes.push(HookOutcome {
1699                    resource: name.clone(),
1700                    ownership,
1701                    detail: HookDetail::SkippedOwnership,
1702                });
1703                continue;
1704            }
1705
1706            let Some(template) = definition
1707                .cleanup
1708                .as_ref()
1709                .and_then(|spec| spec.command.as_deref())
1710            else {
1711                outcomes.push(HookOutcome {
1712                    resource: name.clone(),
1713                    ownership,
1714                    detail: HookDetail::NoHook,
1715                });
1716                continue;
1717            };
1718
1719            let binding = branch.resources.get(name);
1720            let state_ref = self.checkpointed_state_ref(branch, name)?;
1721            let context = RenderContext {
1722                branch_name: &branch.name,
1723                branch_slug: &branch.slug,
1724                workspace: branch.workspace_path.as_str(),
1725                scripts: self.scripts_dir(),
1726                ports: binding.map(|binding| &binding.resolved_ports),
1727                exports: binding.map(|binding| &binding.resolved_exports),
1728                snapshot_path: None,
1729                state_ref: state_ref.as_deref(),
1730            };
1731            let command = render(template, &context);
1732
1733            if let Some(placeholder) = unresolved_placeholder(&command) {
1734                outcomes.push(HookOutcome {
1735                    resource: name.clone(),
1736                    ownership,
1737                    detail: HookDetail::SkippedUnresolved {
1738                        command: command.clone(),
1739                        placeholder: placeholder.to_owned(),
1740                    },
1741                });
1742                continue;
1743            }
1744
1745            if dry_run {
1746                outcomes.push(HookOutcome {
1747                    resource: name.clone(),
1748                    ownership,
1749                    detail: HookDetail::WouldRun(command),
1750                });
1751                continue;
1752            }
1753
1754            let log = self
1755                .store
1756                .action_log_path(&branch.slug, &format!("{name}.cleanup"));
1757            let env = self.assemble_env(branch)?;
1758            let (code, _) = run_captured(&command, &cwd, &env, &log)?;
1759            outcomes.push(HookOutcome {
1760                resource: name.clone(),
1761                ownership,
1762                detail: HookDetail::Ran {
1763                    command,
1764                    ok: code == 0,
1765                    log,
1766                },
1767            });
1768        }
1769        Ok(outcomes)
1770    }
1771
1772    /// The most recent checkpointed state reference for one resource, which
1773    /// is what a cleanup hook's `{{state_ref}}` means: the handle newgit last
1774    /// recorded. Deposited content resolves to its path, like restore.
1775    fn checkpointed_state_ref(
1776        &self,
1777        branch: &BranchInstance,
1778        resource: &str,
1779    ) -> Result<Option<String>> {
1780        let records = self.checkpoint_log(branch).list()?;
1781        for record in records.iter().rev() {
1782            if let Some(state) = record
1783                .resource_states
1784                .iter()
1785                .find(|state| state.name == resource)
1786            {
1787                let resolved = state
1788                    .state_path
1789                    .as_ref()
1790                    .map(ToString::to_string)
1791                    .or_else(|| state.state_ref.clone());
1792                if resolved.is_some() {
1793                    return Ok(resolved);
1794                }
1795            }
1796        }
1797        Ok(None)
1798    }
1799
1800    /// Garbage collection across everything: finish instances whose
1801    /// workspace is gone, delete unclaimed workspaces and dead process
1802    /// state, and prune lane revs nothing references.
1803    ///
1804    /// `remove` targets one instance; this is the sweep. It never deletes a
1805    /// checkpoint record, and never a lane rev a checkpoint still points at —
1806    /// unless `archived = Purge`, which discards the checkpoint logs of
1807    /// instances whose binding record is already gone, and so releases the
1808    /// revs those logs were the only claim on.
1809    pub fn cleanup(&self, dry_run: bool, archived: ArchivedCheckpoints) -> Result<CleanupOutcome> {
1810        let mut outcome = CleanupOutcome {
1811            dry_run,
1812            ..CleanupOutcome::default()
1813        };
1814        let branches = self.store.load_branches()?;
1815
1816        // An instance with no workspace cannot run, checkpoint, or undo, and
1817        // its name stays taken — so finishing the teardown is the only move
1818        // that helps. Its binding record is archived, not deleted.
1819        let (live, stale): (Vec<_>, Vec<_>) = branches
1820            .iter()
1821            .partition(|branch| branch.workspace_path.is_dir());
1822
1823        for branch in &stale {
1824            let hooks = self.run_cleanup_hooks(branch, dry_run)?;
1825            let archived_record = if dry_run {
1826                None
1827            } else {
1828                let state_dir = self.store.instance_state_dir(&branch.slug);
1829                if state_dir.exists() {
1830                    std::fs::remove_dir_all(&state_dir)
1831                        .map_err(|source| NewgitError::io(state_dir, source))?;
1832                }
1833                Some(self.store.archive_branch_record(branch)?)
1834            };
1835            outcome.finalized.push(FinalizedInstance {
1836                name: branch.name.clone(),
1837                workspace: branch.workspace_path.clone(),
1838                hooks,
1839                archived_record,
1840            });
1841        }
1842
1843        // Unclaimed workspace directories: a failed spawn, or a record
1844        // archived while its directory survived.
1845        let workspace_root = self.config.workspace_root(&self.store.paths().project_root);
1846        let (orphans, unrecognized) = orphan_workspaces(&workspace_root, &branches)?;
1847        outcome.warnings.extend(unrecognized);
1848        for orphan in orphans {
1849            if !dry_run {
1850                std::fs::remove_dir_all(&orphan)
1851                    .map_err(|source| NewgitError::io(&orphan, source))?;
1852            }
1853            outcome.orphan_workspaces.push(orphan);
1854        }
1855
1856        // Dead process state: PID files whose group exited, and state
1857        // directories belonging to no live instance.
1858        for branch in &live {
1859            outcome
1860                .dead_state
1861                .extend(self.supervisor(branch).prune_dead_pids(dry_run)?);
1862        }
1863        let live_state_dirs: BTreeSet<Utf8PathBuf> = live
1864            .iter()
1865            .map(|branch| self.store.instance_state_dir(&branch.slug))
1866            .collect();
1867        for state_dir in self.store.state_dirs()? {
1868            if live_state_dirs.contains(&state_dir) {
1869                continue;
1870            }
1871            if !dry_run {
1872                std::fs::remove_dir_all(&state_dir)
1873                    .map_err(|source| NewgitError::io(&state_dir, source))?;
1874            }
1875            outcome.dead_state.push(state_dir);
1876        }
1877
1878        let surviving: Vec<BranchInstance> = live.into_iter().cloned().collect();
1879
1880        // Checkpoint logs of instances that no longer have a binding record —
1881        // including any this pass just finalized. Only when asked: these are
1882        // undo history, not garbage.
1883        if archived == ArchivedCheckpoints::Purge {
1884            let live_slugs: BTreeSet<&str> = surviving
1885                .iter()
1886                .map(|branch| branch.slug.as_str())
1887                .collect();
1888            for slug in self.store.checkpointed_slugs()? {
1889                if live_slugs.contains(slug.as_str()) {
1890                    continue;
1891                }
1892                if let Some(purged) = self.purge_checkpoints(&slug, dry_run)? {
1893                    outcome.purged_checkpoints.push(purged);
1894                }
1895            }
1896        }
1897
1898        // Lane pruning. Roots come from the records that survive this pass,
1899        // so a dry run reports exactly what a real run would remove.
1900        let roots = SnapshotRoots::collect(&self.store, &surviving, archived)?;
1901        for lane_rev in lane_revs(&self.store.paths().snapshots)? {
1902            if !lane_rev.is_staging && roots.contains(&lane_rev.tracker, &lane_rev.rev) {
1903                continue;
1904            }
1905            if !dry_run {
1906                std::fs::remove_dir_all(&lane_rev.path)
1907                    .map_err(|source| NewgitError::io(&lane_rev.path, source))?;
1908            }
1909            outcome.pruned.push(PrunedRev {
1910                tracker: lane_rev.tracker,
1911                rev: lane_rev.rev,
1912                path: lane_rev.path,
1913            });
1914        }
1915        outcome.pinned_by_checkpoints = roots.pinned_only_by_checkpoints().count();
1916        outcome.pinned_by_archived = roots.pinned_only_by_archived_checkpoints().count();
1917
1918        Ok(outcome)
1919    }
1920
1921    /// Write a branch instance's content out as an ordinary Git repository.
1922    ///
1923    /// Tracker audience is the default filter and it fails closed: only
1924    /// `public` lanes ship unless `--include` names a path. This is
1925    /// path-level filtering and nothing more — no hunk privacy, no
1926    /// concealment claim.
1927    pub fn export(
1928        &self,
1929        instance: &str,
1930        destination: &Utf8Path,
1931        filter: &ExportFilter,
1932    ) -> Result<ExportOutcome> {
1933        let branch = self.store.find_branch(instance)?;
1934        self.require_workspace(&branch)?;
1935        prepare_destination(destination)?;
1936
1937        let workspace = branch.workspace_path.clone();
1938        let source_files = GitSource::workspace_tracked_files(&workspace)?;
1939        let plan = export::plan(&workspace, &source_files, &self.trackers, filter)?;
1940
1941        if plan.files.is_empty() {
1942            return Err(NewgitError::Unsupported(format!(
1943                "nothing to export from `{}`: every candidate path was withheld by audience or \
1944                 excluded",
1945                branch.name
1946            )));
1947        }
1948
1949        // Rendered source paths come from HEAD, not from disk. `--skip-worktree`
1950        // does not remove a path from `git ls-files`, and export otherwise
1951        // copies tracked files as they stand — which would ship this
1952        // instance's ports in a repo whose whole point is being clean.
1953        let rendered = self.rendered_source_paths(&branch);
1954        for file in &plan.files {
1955            let target = destination.join(&file.path);
1956            if rendered.contains(&file.path) {
1957                let committed =
1958                    GitSource::workspace_show_head(&workspace, &file.path)?.unwrap_or_default();
1959                if let Some(parent) = target.parent() {
1960                    crate::materializer::create_dir_all(parent)?;
1961                }
1962                std::fs::write(&target, committed)
1963                    .map_err(|source| NewgitError::io(target, source))?;
1964                continue;
1965            }
1966            copy_file(&workspace.join(&file.path), &target)?;
1967        }
1968
1969        let head_rev = GitSource::workspace_head(&workspace)?;
1970        let commit = GitSource::init_export_repo(
1971            destination,
1972            &branch.source_ref,
1973            &format!(
1974                "Export of `{}` at {}",
1975                branch.name,
1976                &head_rev[..8.min(head_rev.len())]
1977            ),
1978        )?;
1979
1980        Ok(ExportOutcome {
1981            destination: destination.to_path_buf(),
1982            branch: branch.source_ref.clone(),
1983            instance: branch.name.clone(),
1984            source_head: head_rev,
1985            commit,
1986            plan,
1987        })
1988    }
1989
1990    /// Record one coherent snapshot across source, trackers, and resources.
1991    pub fn checkpoint(&self, instance: &str, message: Option<&str>) -> Result<CheckpointOutcome> {
1992        self.require_resolvable_graph()?;
1993        let mut branch = self.store.find_branch(instance)?;
1994        self.checkpoint_branch(&mut branch, message, CheckpointReason::Explicit)
1995    }
1996
1997    pub fn list_checkpoints(&self, instance: &str) -> Result<Vec<CheckpointRecord>> {
1998        let branch = self.store.find_branch(instance)?;
1999        self.checkpoint_log(&branch).list()
2000    }
2001
2002    fn checkpoint_branch(
2003        &self,
2004        branch: &mut BranchInstance,
2005        message: Option<&str>,
2006        reason: CheckpointReason,
2007    ) -> Result<CheckpointOutcome> {
2008        self.require_workspace(branch)?;
2009        // A checkpoint is the promise that this state can be returned to.
2010        // Hand edits to a rendered file are the one thing it cannot carry —
2011        // they are excluded from the capture by design — so this is exactly
2012        // the moment to name them, while they still exist.
2013        let mut warnings = self.render_drift(branch);
2014        let checkpoint_log = self.checkpoint_log(branch);
2015        let id = checkpoint_log.next_id()?;
2016        let workspace = branch.workspace_path.clone();
2017
2018        // Source: the committed state plus a dangling commit for anything
2019        // uncommitted, fetched into the store. The store learns about
2020        // workspace commits only here — checkpoint is the blessing boundary,
2021        // and a checkpoint protects the worktree as it stands, not just what
2022        // the agent remembered to commit.
2023        let head_rev = GitSource::workspace_head(&workspace)?;
2024        let dirty_rev = GitSource::workspace_dirty_commit(
2025            &workspace,
2026            &format!("newgit {id}: uncommitted state of `{}`", branch.name),
2027            &self.rendered_source_paths(branch),
2028        )?;
2029        let tip = dirty_rev.clone().unwrap_or_else(|| head_rev.clone());
2030        let workspace_ref = format!("refs/newgit/checkpoints/{id}");
2031        let store_ref = format!("refs/newgit/checkpoints/{}/{id}", branch.slug);
2032        GitSource::workspace_update_ref(&workspace, &workspace_ref, &tip)?;
2033        let fetched = self
2034            .source
2035            .fetch_ref(&workspace, &workspace_ref, &store_ref);
2036        GitSource::workspace_delete_ref(&workspace, &workspace_ref)?;
2037        fetched?;
2038        self.bless_store_branch(branch, &head_rev, None, &mut warnings)?;
2039
2040        // Resources, dependents first: a dependent's state may be derived
2041        // from its dependency, so it is captured before the dependency moves.
2042        let mut resource_states = Vec::new();
2043        let mut deposits: Vec<(String, String)> = Vec::new();
2044        for name in self.resource_order.iter().rev() {
2045            let Some(binding) = branch.resources.get(name) else {
2046                continue;
2047            };
2048            let definition = self.resource_definition(name)?;
2049            let was_running = self.supervisor(branch).running_pid(name).is_some();
2050            let captured = self.checkpoint_resource(branch, definition)?;
2051            if let Some(deposit) = captured.deposit {
2052                deposits.push(deposit);
2053            }
2054            resource_states.push(ResourceState {
2055                name: name.clone(),
2056                definition_rev: definition.definition_rev.clone(),
2057                mode: captured.mode,
2058                state_ref: captured.state_ref,
2059                state_path: captured.state_path,
2060                was_running,
2061                resolved_ports: binding.resolved_ports.clone(),
2062                resolved_exports: binding.resolved_exports.clone(),
2063            });
2064        }
2065        // Recorded in dependency order for readability.
2066        resource_states.reverse();
2067        for (tracker, rev) in deposits {
2068            let definition = self.definition(&tracker)?;
2069            branch.trackers.insert(
2070                tracker,
2071                TrackerBinding {
2072                    definition_rev: definition.definition_rev.clone(),
2073                    content_rev: Some(rev),
2074                },
2075            );
2076        }
2077
2078        // Trackers: capture every owned path (dedupes in the lane).
2079        // Deposit-only lanes record whatever rev the deposit or an earlier
2080        // capture bound.
2081        let mut tracker_states = Vec::new();
2082        for definition in &self.trackers {
2083            let content_rev = if definition.paths.is_empty() {
2084                branch
2085                    .trackers
2086                    .get(&definition.name)
2087                    .and_then(|binding| binding.content_rev.clone())
2088            } else {
2089                Some(
2090                    self.lane(&definition.name)
2091                        .capture(&workspace, definition)?
2092                        .rev,
2093                )
2094            };
2095            branch.trackers.insert(
2096                definition.name.clone(),
2097                TrackerBinding {
2098                    definition_rev: definition.definition_rev.clone(),
2099                    content_rev: content_rev.clone(),
2100                },
2101            );
2102            tracker_states.push(TrackerState {
2103                name: definition.name.clone(),
2104                definition_rev: definition.definition_rev.clone(),
2105                content_rev,
2106            });
2107        }
2108
2109        let record = CheckpointRecord {
2110            id,
2111            branch: branch.name.clone(),
2112            created_at: Utc::now(),
2113            message: message.map(ToOwned::to_owned),
2114            reason,
2115            undo_completed: None,
2116            source: SourceState {
2117                head_rev,
2118                dirty_rev,
2119                store_ref,
2120            },
2121            tracker_states,
2122            resource_states,
2123        };
2124        let record_path = checkpoint_log.save(&record)?;
2125        branch.updated_at = Utc::now();
2126        self.store.save_branch_record(branch)?;
2127
2128        Ok(CheckpointOutcome {
2129            record,
2130            record_path,
2131            warnings,
2132        })
2133    }
2134
2135    fn checkpoint_resource(
2136        &self,
2137        branch: &BranchInstance,
2138        definition: &ResourceDefinition,
2139    ) -> Result<CapturedResource> {
2140        let Some(spec) = &definition.checkpoint else {
2141            return Ok(CapturedResource::none());
2142        };
2143        let binding = branch.resources.get(&definition.name);
2144
2145        match spec.mode {
2146            CheckpointMode::None => Ok(CapturedResource::none()),
2147            CheckpointMode::Hash => {
2148                let files = collect_files(&branch.workspace_path, &spec.paths)?;
2149                let rev = content_rev(&files)?;
2150                Ok(CapturedResource {
2151                    mode: "hash".to_owned(),
2152                    state_ref: Some(format!("hash:{rev}")),
2153                    state_path: None,
2154                    deposit: None,
2155                })
2156            }
2157            CheckpointMode::Command => {
2158                let template = spec.command.as_deref().expect("validated at parse time");
2159
2160                // Checked here, not at manager open: erroring at open would
2161                // make `newgit tracker create <missing>` — the fix — fail too.
2162                if let Some(tracker) = &spec.into_tracker
2163                    && !self.trackers.iter().any(|t| &t.name == tracker)
2164                {
2165                    return Err(NewgitError::InvalidDefinition {
2166                        tracker: definition.name.clone(),
2167                        reason: format!(
2168                            "checkpoint `into_tracker = \"{tracker}\"` names a tracker that is \
2169                             not defined; create it with `newgit tracker create {tracker}`"
2170                        ),
2171                    });
2172                }
2173
2174                // `into_tracker` commands write into a staging dir that is
2175                // deposited into the lane afterwards — the one seam between
2176                // resources and trackers.
2177                let staging = spec
2178                    .into_tracker
2179                    .as_ref()
2180                    .map(|_| {
2181                        tempfile::tempdir()
2182                            .map_err(|source| NewgitError::io(&branch.workspace_path, source))
2183                    })
2184                    .transpose()?;
2185                let staging_path = staging
2186                    .as_ref()
2187                    .map(|dir| {
2188                        Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
2189                            .map_err(|path| NewgitError::NonUtf8Path(path.display().to_string()))
2190                    })
2191                    .transpose()?;
2192
2193                let context = RenderContext {
2194                    branch_name: &branch.name,
2195                    branch_slug: &branch.slug,
2196                    workspace: branch.workspace_path.as_str(),
2197                    scripts: self.scripts_dir(),
2198                    ports: binding.map(|binding| &binding.resolved_ports),
2199                    exports: binding.map(|binding| &binding.resolved_exports),
2200                    snapshot_path: staging_path.as_deref().map(Utf8Path::as_str),
2201                    state_ref: None,
2202                };
2203                let command = render(template, &context);
2204                let log = self
2205                    .store
2206                    .action_log_path(&branch.slug, &format!("{}.checkpoint", definition.name));
2207                let env = self.assemble_env(branch)?;
2208                let (code, stdout) = run_captured(&command, &branch.workspace_path, &env, &log)?;
2209                if code != 0 {
2210                    return Err(NewgitError::CheckpointCommandFailed {
2211                        resource: definition.name.clone(),
2212                        code,
2213                        log,
2214                    });
2215                }
2216
2217                match (&spec.into_tracker, &staging_path) {
2218                    (Some(tracker), Some(staging_path)) => {
2219                        let lane = self.lane(tracker);
2220                        let deposit = lane.deposit(staging_path)?;
2221                        // A path the command echoed under {{snapshot.path}}
2222                        // maps to the same relative location inside the lane.
2223                        let state_path = Utf8Path::new(&stdout)
2224                            .strip_prefix(staging_path)
2225                            .map(|relative| lane.rev_path(&deposit.rev).join(relative))
2226                            .unwrap_or_else(|_| lane.rev_path(&deposit.rev));
2227                        Ok(CapturedResource {
2228                            mode: "command".to_owned(),
2229                            state_ref: Some(format!("tracker:{tracker}@{}", deposit.rev)),
2230                            state_path: Some(state_path),
2231                            deposit: Some((tracker.clone(), deposit.rev)),
2232                        })
2233                    }
2234                    _ => Ok(CapturedResource {
2235                        mode: "command".to_owned(),
2236                        state_ref: (!stdout.is_empty()).then_some(stdout),
2237                        state_path: None,
2238                        deposit: None,
2239                    }),
2240                }
2241            }
2242            CheckpointMode::External => {
2243                let template = spec.state_ref.as_deref().expect("validated at parse time");
2244                let context = RenderContext {
2245                    branch_name: &branch.name,
2246                    branch_slug: &branch.slug,
2247                    workspace: branch.workspace_path.as_str(),
2248                    scripts: self.scripts_dir(),
2249                    ports: binding.map(|binding| &binding.resolved_ports),
2250                    exports: binding.map(|binding| &binding.resolved_exports),
2251                    ..RenderContext::default()
2252                };
2253                Ok(CapturedResource {
2254                    mode: "external".to_owned(),
2255                    state_ref: Some(render(template, &context)),
2256                    state_path: None,
2257                    deposit: None,
2258                })
2259            }
2260        }
2261    }
2262
2263    /// Restore the branch instance to a checkpoint — the latest, unless
2264    /// `to` names one. The current state is checkpointed first, so undo is
2265    /// always undoable and running it twice is redo.
2266    pub fn undo(&self, instance: &str, to: Option<&str>) -> Result<UndoOutcome> {
2267        self.require_resolvable_graph()?;
2268        let mut branch = self.store.find_branch(instance)?;
2269        self.require_workspace(&branch)?;
2270        let checkpoint_log = self.checkpoint_log(&branch);
2271        let restored = match to {
2272            Some(id) => checkpoint_log.load(id)?,
2273            None => checkpoint_log.latest()?,
2274        };
2275
2276        let safety = self.checkpoint_branch(
2277            &mut branch,
2278            Some(&format!("state before undo to {}", restored.id)),
2279            CheckpointReason::BeforeUndo,
2280        )?;
2281        let mut warnings = safety.warnings.clone();
2282
2283        // Nothing may keep running while content changes underneath it.
2284        let supervisor = self.supervisor(&branch);
2285        for definition in &self.resources {
2286            if supervisor.running_pid(&definition.name).is_some() {
2287                supervisor.stop(&definition.name, &definition.stop_signal())?;
2288            }
2289        }
2290
2291        // Source: content back exactly, uncommitted state uncommitted again.
2292        let workspace = branch.workspace_path.clone();
2293        GitSource::workspace_fetch_ref(&workspace, self.source.root(), &restored.source.store_ref)?;
2294        GitSource::workspace_restore_to(
2295            &workspace,
2296            &restored.source.head_rev,
2297            restored.source.dirty_rev.as_deref(),
2298        )?;
2299        // The pre-undo tip stays reachable from the safety checkpoint's ref,
2300        // so moving the branch back to it is expected, not divergence.
2301        self.bless_store_branch(
2302            &mut branch,
2303            &restored.source.head_rev,
2304            Some(&safety.record.source.head_rev),
2305            &mut warnings,
2306        )?;
2307
2308        // Trackers: plain content, restored exactly; should not partially
2309        // fail in interesting ways, so failures here are hard errors.
2310        let mut trackers = Vec::new();
2311        for state in &restored.tracker_states {
2312            let Some(definition) = self
2313                .trackers
2314                .iter()
2315                .find(|definition| definition.name == state.name)
2316            else {
2317                warnings.push(format!(
2318                    "tracker `{}` from the checkpoint is no longer defined; its content was \
2319                     not restored",
2320                    state.name
2321                ));
2322                continue;
2323            };
2324            if definition.definition_rev != state.definition_rev {
2325                warnings.push(format!(
2326                    "tracker `{}` definition changed since the checkpoint; content was \
2327                     restored against the current definition",
2328                    state.name
2329                ));
2330            }
2331            let files = match &state.content_rev {
2332                Some(rev) => self
2333                    .lane(&definition.name)
2334                    .restore(&workspace, definition, rev)?,
2335                None => {
2336                    clear_owned_paths(&workspace, definition)?;
2337                    0
2338                }
2339            };
2340            branch.trackers.insert(
2341                state.name.clone(),
2342                TrackerBinding {
2343                    definition_rev: definition.definition_rev.clone(),
2344                    content_rev: state.content_rev.clone(),
2345                },
2346            );
2347            trackers.push(UndoTrackerOutcome {
2348                name: state.name.clone(),
2349                rev: state.content_rev.clone(),
2350                files,
2351            });
2352        }
2353
2354        // Source and tracker content have both moved underneath the rendered
2355        // files, so the instance's values go back on top before any resource
2356        // is restored or restarted — a service must not come back up reading
2357        // the committed default port. Cheap, because a render is a pure
2358        // function of committed content and the binding record, and undo has
2359        // just settled both.
2360        warnings.extend(self.rerender_all(&mut branch));
2361
2362        // Resources: dependencies before dependents, restarting what was
2363        // running. Failures are collected into a recovery record, not fatal.
2364        let mut resources = Vec::new();
2365        let mut failures: Vec<RestoreFailure> = Vec::new();
2366        for name in &self.resource_order {
2367            let Some(state) = restored
2368                .resource_states
2369                .iter()
2370                .find(|state| &state.name == name)
2371            else {
2372                continue;
2373            };
2374            if !branch.resources.contains_key(name) {
2375                continue;
2376            }
2377            let definition = self.resource_definition(name)?;
2378            if definition.definition_rev != state.definition_rev {
2379                warnings.push(format!(
2380                    "resource `{name}` definition changed since the checkpoint; restored with \
2381                     the current definition"
2382                ));
2383            }
2384
2385            let (mut action_label, ok) = self.restore_resource(
2386                &mut branch,
2387                definition,
2388                state,
2389                &mut failures,
2390                &mut warnings,
2391            )?;
2392            if let Some(binding) = branch.resources.get_mut(name) {
2393                binding.status = if ok {
2394                    ResourceStatus::Ready
2395                } else {
2396                    ResourceStatus::Failed
2397                };
2398            }
2399
2400            if ok && state.was_running {
2401                match self.restart_long_running(&branch, definition) {
2402                    Ok(true) => action_label.push_str(" + restarted"),
2403                    Ok(false) => warnings.push(format!(
2404                        "resource `{name}` was running at checkpoint time but has no \
2405                         long-running action to restart"
2406                    )),
2407                    Err(error) => failures.push(RestoreFailure {
2408                        resource: name.clone(),
2409                        detail: format!("restart failed: {error}"),
2410                        log: None,
2411                        retry_with: format!("newgit action {name}.start {}", branch.name),
2412                    }),
2413                }
2414            }
2415            resources.push(UndoResourceOutcome {
2416                name: name.clone(),
2417                action: action_label,
2418                ok,
2419            });
2420        }
2421
2422        let recovery_record = if failures.is_empty() {
2423            None
2424        } else {
2425            for failure in &failures {
2426                if let Some(binding) = branch.resources.get_mut(&failure.resource) {
2427                    binding.status = ResourceStatus::Failed;
2428                }
2429            }
2430            Some(checkpoint_log.save_recovery(&RecoveryRecord {
2431                checkpoint: restored.id.clone(),
2432                branch: branch.name.clone(),
2433                created_at: Utc::now(),
2434                failures,
2435            })?)
2436        };
2437
2438        branch.updated_at = Utc::now();
2439        self.store.save_branch_record(&branch)?;
2440
2441        // Now that the undo has finished, the safety checkpoint can say
2442        // whether it is a redo point. A pre-undo snapshot taken before an
2443        // undo that failed captures a state the instance never cleanly left,
2444        // and three failed attempts otherwise leave three of them looking
2445        // exactly like states a human chose to keep.
2446        let mut safety_record = safety.record;
2447        safety_record.undo_completed = Some(recovery_record.is_none());
2448        checkpoint_log.save(&safety_record)?;
2449
2450        Ok(UndoOutcome {
2451            restored,
2452            safety: safety_record,
2453            trackers,
2454            resources,
2455            recovery_record,
2456            warnings,
2457        })
2458    }
2459
2460    /// `branch` is mutable because a recompute restore re-runs `prepare`,
2461    /// which may `capture` a fresh handle — a restored resource must not
2462    /// keep publishing the pre-undo one.
2463    fn restore_resource(
2464        &self,
2465        branch: &mut BranchInstance,
2466        definition: &ResourceDefinition,
2467        state: &ResourceState,
2468        failures: &mut Vec<RestoreFailure>,
2469        warnings: &mut Vec<String>,
2470    ) -> Result<(String, bool)> {
2471        let Some(spec) = &definition.restore else {
2472            return Ok(("none".to_owned(), true));
2473        };
2474        match spec.mode {
2475            RestoreMode::None => Ok(("none".to_owned(), true)),
2476            RestoreMode::External => Ok(("external (no-op)".to_owned(), true)),
2477            RestoreMode::Recompute => {
2478                let action_name = spec.recompute_action();
2479                let action = definition.actions.get(action_name).ok_or_else(|| {
2480                    NewgitError::UnknownAction {
2481                        resource: definition.name.clone(),
2482                        action: action_name.to_owned(),
2483                    }
2484                })?;
2485                let log = self
2486                    .store
2487                    .action_log_path(&branch.slug, &format!("{}.restore", definition.name));
2488                let (code, captured) = self.run_one_shot(branch, definition, action, &log)?;
2489                warnings.extend(Self::missing_capture_warnings(
2490                    &definition.name,
2491                    action_name,
2492                    &captured,
2493                    &log,
2494                ));
2495                Self::apply_captures(branch, &definition.name, captured.found);
2496                let ok = code == 0;
2497                if !ok {
2498                    failures.push(RestoreFailure {
2499                        resource: definition.name.clone(),
2500                        detail: format!("recompute action `{action_name}` exited with {code}"),
2501                        log: Some(log),
2502                        retry_with: format!(
2503                            "newgit action {}.{action_name} {}",
2504                            definition.name, branch.name
2505                        ),
2506                    });
2507                }
2508                Ok((format!("recompute({action_name})"), ok))
2509            }
2510            RestoreMode::Command => {
2511                let template = spec.command.as_deref().expect("validated at parse time");
2512                let binding = branch.resources.get(&definition.name);
2513                let state_ref = state
2514                    .state_path
2515                    .as_ref()
2516                    .map(ToString::to_string)
2517                    .or_else(|| state.state_ref.clone());
2518                let context = RenderContext {
2519                    branch_name: &branch.name,
2520                    branch_slug: &branch.slug,
2521                    workspace: branch.workspace_path.as_str(),
2522                    scripts: self.scripts_dir(),
2523                    ports: binding.map(|binding| &binding.resolved_ports),
2524                    exports: binding.map(|binding| &binding.resolved_exports),
2525                    snapshot_path: None,
2526                    state_ref: state_ref.as_deref(),
2527                };
2528                let command = render(template, &context);
2529                let log = self
2530                    .store
2531                    .action_log_path(&branch.slug, &format!("{}.restore", definition.name));
2532                let env = self.assemble_env(branch)?;
2533                let (code, _) = run_captured(&command, &branch.workspace_path, &env, &log)?;
2534                let ok = code == 0;
2535                if !ok {
2536                    failures.push(RestoreFailure {
2537                        resource: definition.name.clone(),
2538                        detail: format!("restore command exited with {code}"),
2539                        log: Some(log),
2540                        retry_with: "repair the resource, then `newgit undo` again".to_owned(),
2541                    });
2542                }
2543                Ok(("command".to_owned(), ok))
2544            }
2545        }
2546    }
2547
2548    /// Start the definition's long-running action again after an undo.
2549    /// `Ok(false)` when the definition has none.
2550    fn restart_long_running(
2551        &self,
2552        branch: &BranchInstance,
2553        definition: &ResourceDefinition,
2554    ) -> Result<bool> {
2555        let Some((action_name, action)) = definition
2556            .actions
2557            .iter()
2558            .find(|(_, action)| action.long_running)
2559        else {
2560            return Ok(false);
2561        };
2562        let log = self
2563            .store
2564            .action_log_path(&branch.slug, &format!("{}.{action_name}", definition.name));
2565        let command = self.rendered_command(branch, definition, action)?;
2566        let env = self.assemble_env(branch)?;
2567        self.supervisor(branch).start(
2568            &definition.name,
2569            &command,
2570            &branch.workspace_path,
2571            &env,
2572            &log,
2573        )?;
2574        Ok(true)
2575    }
2576
2577    /// Point the store's branch ref at the checkpointed head. The branch is
2578    /// owned by this instance — divergence is policy, not mechanism — so a
2579    /// store-side advance is warned about loudly, never hard-refused.
2580    /// `expected_old` silences the warning when the ref is knowingly moved
2581    /// backwards from a rev a checkpoint ref keeps alive (undo).
2582    fn bless_store_branch(
2583        &self,
2584        branch: &mut BranchInstance,
2585        head_rev: &str,
2586        expected_old: Option<&str>,
2587        warnings: &mut Vec<String>,
2588    ) -> Result<()> {
2589        let branch_ref = format!("refs/heads/{}", branch.source_ref);
2590        if let Some(old) = self.source.ref_rev(&branch_ref)
2591            && old != head_rev
2592            && expected_old != Some(old.as_str())
2593            && !self.source.is_ancestor(&old, head_rev)?
2594        {
2595            warnings.push(format!(
2596                "store branch `{}` had commits this workspace does not (was at {}); it now \
2597                 points at {} — the old commits remain in the store repository but no branch \
2598                 ref reaches them",
2599                branch.source_ref,
2600                &old[..8.min(old.len())],
2601                &head_rev[..8.min(head_rev.len())]
2602            ));
2603        }
2604        self.source.update_ref(&branch_ref, head_rev)?;
2605        branch.source_rev = head_rev.to_owned();
2606        Ok(())
2607    }
2608
2609    fn checkpoint_log(&self, branch: &BranchInstance) -> CheckpointLog {
2610        CheckpointLog::new(self.store.checkpoint_dir(&branch.slug), &branch.name)
2611    }
2612
2613    fn definition(&self, tracker: &str) -> Result<&TrackerDefinition> {
2614        self.trackers
2615            .iter()
2616            .find(|definition| definition.name == tracker)
2617            .ok_or_else(|| NewgitError::UnknownTracker(tracker.to_owned()))
2618    }
2619
2620    fn definition_or_load(&self, tracker: &str) -> Result<TrackerDefinition> {
2621        if let Some(definition) = self
2622            .trackers
2623            .iter()
2624            .find(|definition| definition.name == tracker)
2625        {
2626            return Ok(definition.clone());
2627        }
2628        let path = self.store.paths().trackers.join(format!("{tracker}.toml"));
2629        if path.is_file() {
2630            return TrackerDefinition::from_file(tracker, &path);
2631        }
2632        Err(NewgitError::UnknownTracker(tracker.to_owned()))
2633    }
2634
2635    fn lane(&self, tracker: &str) -> TrackerLane {
2636        TrackerLane::new(&self.store.paths().snapshots, tracker)
2637    }
2638
2639    fn require_workspace(&self, branch: &BranchInstance) -> Result<()> {
2640        if branch.workspace_path.is_dir() {
2641            Ok(())
2642        } else {
2643            Err(NewgitError::Unsupported(format!(
2644                "the workspace for `{}` is missing at {}; spawn it again or remove the instance",
2645                branch.name, branch.workspace_path
2646            )))
2647        }
2648    }
2649}
2650
2651/// What one resource's checkpoint mode produced.
2652struct CapturedResource {
2653    mode: String,
2654    state_ref: Option<String>,
2655    state_path: Option<Utf8PathBuf>,
2656    /// Lane deposit made via `into_tracker`: (tracker, rev).
2657    deposit: Option<(String, String)>,
2658}
2659
2660impl CapturedResource {
2661    fn none() -> Self {
2662        Self {
2663            mode: "none".to_owned(),
2664            state_ref: None,
2665            state_path: None,
2666            deposit: None,
2667        }
2668    }
2669}
2670
2671/// How many lines differ between the expected render and what is on disk —
2672/// enough to say how big the discarded edit is without printing a diff.
2673fn differing_lines(expected: &str, actual: &str) -> usize {
2674    let expected: Vec<&str> = expected.lines().collect();
2675    let actual: Vec<&str> = actual.lines().collect();
2676    let common = expected
2677        .iter()
2678        .zip(actual.iter())
2679        .filter(|(left, right)| left != right)
2680        .count();
2681    common + expected.len().abs_diff(actual.len())
2682}
2683
2684fn validate_disjoint_with_replacement(
2685    definitions: &[TrackerDefinition],
2686    replacement: &TrackerDefinition,
2687) -> Result<()> {
2688    let mut updated = Vec::with_capacity(definitions.len());
2689    let mut replaced = false;
2690    for definition in definitions {
2691        if definition.name == replacement.name {
2692            updated.push(replacement.clone());
2693            replaced = true;
2694        } else {
2695            updated.push(definition.clone());
2696        }
2697    }
2698    if !replaced {
2699        updated.push(replacement.clone());
2700    }
2701    crate::tracker::validate_disjoint(&updated)
2702}