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    CleanupOutcome, FinalizedInstance, HookDetail, HookOutcome, PrunedRev, SnapshotRoots,
15    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::resource::{
25    CheckpointMode, ResourceDefinition, RestoreMode, parse_captures, topological_order,
26};
27use crate::source::GitSource;
28use crate::store::MetadataStore;
29use crate::supervisor::{StopOutcome, Supervisor, run_captured, run_foreground};
30use crate::templates::resource_template;
31use crate::tracker::{Storage, TrackerDefinition, collect_files, collect_owned_files, content_rev};
32
33/// Orchestrates branch-instance lifecycle against one store.
34#[derive(Debug)]
35pub struct BranchManager {
36    store: MetadataStore,
37    config: ProjectConfig,
38    source: GitSource,
39    trackers: Vec<TrackerDefinition>,
40    resources: Vec<ResourceDefinition>,
41    /// Resource names, dependencies before dependents.
42    resource_order: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SpawnOutcome {
47    pub branch: BranchInstance,
48    pub record_path: Utf8PathBuf,
49    /// False when the instance attached to a pre-existing source branch.
50    pub created_source_branch: bool,
51    pub trackers: Vec<TrackerBindOutcome>,
52    pub resources: Vec<ResourceBindOutcome>,
53}
54
55/// How a resource was bound at spawn.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ResourceBindOutcome {
58    pub name: String,
59    pub ports: BTreeMap<String, u16>,
60    pub status: ResourceStatus,
61    /// Present when a `prepare` action ran: (succeeded, log path).
62    pub prepare: Option<(bool, Utf8PathBuf)>,
63    /// Resource dependencies that prevented `prepare` from running.
64    pub blocked_by: Vec<String>,
65    /// Export names `prepare` published through `captures`.
66    pub captured: Vec<String>,
67}
68
69/// What running an action did.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ActionOutcome {
72    /// Long-running action started under supervision.
73    Started {
74        pid: u32,
75        log: Utf8PathBuf,
76    },
77    Stopped(StopOutcome),
78    /// One-shot command finished with this exit code.
79    Ran {
80        code: i32,
81        log: Utf8PathBuf,
82    },
83}
84
85/// How a tracker's content landed in a workspace.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct TrackerBindOutcome {
88    pub name: String,
89    pub content_rev: Option<String>,
90    pub files: usize,
91    pub origin: BindOrigin,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum BindOrigin {
96    /// Projected from the lane head.
97    LaneHead,
98    /// Bound with no captured content yet.
99    Nothing,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct InstanceReport {
104    pub branch: BranchInstance,
105    pub workspace_exists: bool,
106    /// Live HEAD of the workspace clone, when it can be read.
107    pub live_rev: Option<String>,
108    pub trackers: Vec<TrackerReport>,
109    pub resources: Vec<ResourceReport>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ResourceReport {
114    pub name: String,
115    /// `running`, `stopped`, `ready`, `pending`, `failed`, or `—` (unbound).
116    pub state: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct TrackerReport {
121    pub name: String,
122    /// None when the tracker is defined but this instance has no binding.
123    pub content_rev: Option<String>,
124    /// The lane head/default, when one has been merged.
125    pub lane_head: Option<String>,
126}
127
128impl TrackerReport {
129    /// The lane has content this instance never had: pulling is safe advice.
130    pub fn never_pulled(&self) -> bool {
131        self.lane_head.is_some() && self.content_rev.is_none()
132    }
133
134    /// Bound content differs from the lane head. Without rev ancestry the
135    /// direction is unknowable — this instance may be ahead, behind, or
136    /// diverged — so callers must not advise one direction.
137    pub fn diverged(&self) -> bool {
138        self.content_rev.is_some() && self.lane_head.is_some() && self.content_rev != self.lane_head
139    }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct RemoveOutcome {
144    pub branch: BranchInstance,
145    pub archived_record: Utf8PathBuf,
146    /// What each resource's cleanup hook did, dependents first.
147    pub hooks: Vec<HookOutcome>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ExportOutcome {
152    pub destination: Utf8PathBuf,
153    /// Branch name in the exported repository (the instance's source ref).
154    pub branch: String,
155    pub instance: String,
156    /// Workspace HEAD the export was taken from.
157    pub source_head: String,
158    /// The single commit the export produced.
159    pub commit: String,
160    pub plan: ExportPlan,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct CheckpointOutcome {
165    pub record: CheckpointRecord,
166    pub record_path: Utf8PathBuf,
167    pub warnings: Vec<String>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct UndoOutcome {
172    /// The checkpoint that was restored.
173    pub restored: CheckpointRecord,
174    /// Safety checkpoint taken first — restoring it again is redo.
175    pub safety: CheckpointRecord,
176    pub trackers: Vec<UndoTrackerOutcome>,
177    pub resources: Vec<UndoResourceOutcome>,
178    /// Written when any resource restore failed.
179    pub recovery_record: Option<Utf8PathBuf>,
180    pub warnings: Vec<String>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct UndoTrackerOutcome {
185    pub name: String,
186    /// None means the checkpoint had no content: owned paths were cleared.
187    pub rev: Option<String>,
188    pub files: usize,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct UndoResourceOutcome {
193    /// What the restore did, for display: `none`, `recompute(prepare)`,
194    /// `command`, `external (no-op)`; `+ restarted` when a process came back.
195    pub action: String,
196    pub name: String,
197    pub ok: bool,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct CaptureReport {
202    pub rev: String,
203    pub files: usize,
204    /// False when the content was identical to the previous binding.
205    pub changed: bool,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct RestoreReport {
210    pub rev: String,
211    pub files: usize,
212    /// Where the pre-restore content was saved, when it differed.
213    pub safety_rev: Option<String>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct MergeTrackerOutcome {
218    pub tracker: String,
219    pub rev: String,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct AddTrackerOutcome {
224    pub path: Utf8PathBuf,
225    pub ignored_patterns: Vec<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct TrackPathsOutcome {
230    pub path: Utf8PathBuf,
231    pub added_paths: Vec<Utf8PathBuf>,
232    pub ignored_patterns: Vec<String>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct AddResourceOutcome {
237    pub path: Utf8PathBuf,
238    /// Companion definitions created because the template depends on them.
239    pub companions_created: Vec<Utf8PathBuf>,
240    /// Tracker lanes created because the template deposits into them.
241    pub trackers_created: Vec<Utf8PathBuf>,
242}
243
244impl BranchManager {
245    pub fn open(store: MetadataStore) -> Result<Self> {
246        store.ensure_initialized()?;
247        let config = store.load_config()?;
248        let source = GitSource::open(&store.paths().project_root, config.project.source)?;
249        let trackers = store.load_tracker_definitions()?;
250        let resources = store.load_resource_definitions()?;
251        let tracker_names: BTreeSet<String> = trackers.iter().map(|t| t.name.clone()).collect();
252        let resource_order = topological_order(&resources, &tracker_names)?;
253        Ok(Self {
254            store,
255            config,
256            source,
257            trackers,
258            resources,
259            resource_order,
260        })
261    }
262
263    pub fn store(&self) -> &MetadataStore {
264        &self.store
265    }
266
267    pub fn tracker_definitions(&self) -> &[TrackerDefinition] {
268        &self.trackers
269    }
270
271    /// The tracker-path invariant, checked loudly: non-source tracker paths
272    /// must be gitignored unless deliberately dual-tracked with source.
273    pub fn gitignore_warnings(&self) -> Vec<String> {
274        let mut warnings = Vec::new();
275        for definition in &self.trackers {
276            for path in &definition.paths {
277                if let Ok(true) = self.source.is_tracked(path.as_str()) {
278                    warnings.push(format!(
279                        "tracker `{}` owns `{path}`, which Git also tracks (dual-tracked): \
280                         branch-local content will show as modifications and can be committed \
281                         into source history — untrack it with `git rm --cached {path}` unless \
282                         this is deliberate",
283                        definition.name
284                    ));
285                } else if let Ok(false) = self.source.is_ignored(path.as_str()) {
286                    warnings.push(format!(
287                        "tracker `{}` owns `{path}` but the store repo does not gitignore it; \
288                         agents may commit it into source history (fine only if deliberately \
289                         dual-tracked)",
290                        definition.name
291                    ));
292                }
293            }
294        }
295        warnings
296    }
297
298    pub fn spawn(&self, name: &str, from: Option<&str>) -> Result<SpawnOutcome> {
299        validate_name(name)?;
300
301        let slug = branch_slug(name);
302        let record_path = self.store.branch_record_path(&slug);
303        if record_path.exists() {
304            return Err(NewgitError::BranchInstanceExists {
305                name: name.to_owned(),
306                path: record_path,
307            });
308        }
309
310        let created_source_branch = if self.source.branch_exists(name)? {
311            if let Some(base) = from {
312                return Err(NewgitError::Unsupported(format!(
313                    "source branch `{name}` already exists; `--from {base}` only applies when \
314                     creating a new branch"
315                )));
316            }
317            false
318        } else {
319            let base = from.unwrap_or("HEAD");
320            let base_rev = self.source.rev_parse(base)?;
321            self.source.create_branch(name, &base_rev)?;
322            true
323        };
324
325        let source_rev = self.source.rev_parse(&format!("refs/heads/{name}"))?;
326        let workspace_path = self
327            .config
328            .workspace_root(&self.store.paths().project_root)
329            .join(&slug);
330        let mut branch = BranchInstance::new(name, name, source_rev, workspace_path)?;
331
332        RealDirMaterializer.materialize(&self.source, &branch)?;
333
334        // Before any lane content lands, make the clone's Git ignore the
335        // paths those lanes own — otherwise projected content arrives as
336        // untracked files an agent can commit into source history.
337        let owned: Vec<Utf8PathBuf> = self
338            .trackers
339            .iter()
340            .flat_map(|definition| definition.paths.iter().cloned())
341            .collect();
342        exclude_tracker_paths(&branch.workspace_path, &owned)?;
343
344        let mut tracker_outcomes = Vec::new();
345        for definition in &self.trackers {
346            let outcome = self.bind_tracker(&mut branch, definition, false)?;
347            tracker_outcomes.push(outcome);
348        }
349
350        let resource_outcomes = self.bind_resources(&mut branch)?;
351
352        let record_path = self.store.create_branch_record(&branch)?;
353
354        Ok(SpawnOutcome {
355            branch,
356            record_path,
357            created_source_branch,
358            trackers: tracker_outcomes,
359            resources: resource_outcomes,
360        })
361    }
362
363    /// Allocate ports, render exports, and run `prepare` hooks in dependency
364    /// order. Prepare failures are loud but leave the instance spawned —
365    /// re-run with `newgit action <resource>.prepare`.
366    fn bind_resources(&self, branch: &mut BranchInstance) -> Result<Vec<ResourceBindOutcome>> {
367        let mut used = ports::used_ports(&self.store.load_branches()?);
368        let mut outcomes = Vec::new();
369
370        for name in &self.resource_order {
371            let definition = self.resource_definition(name)?;
372
373            let mut resolved_ports = BTreeMap::new();
374            for (port_name, request) in &definition.ports {
375                resolved_ports.insert(
376                    port_name.clone(),
377                    ports::allocate(request.start, &mut used)?,
378                );
379            }
380
381            let context = RenderContext {
382                branch_name: &branch.name,
383                branch_slug: &branch.slug,
384                workspace: branch.workspace_path.as_str(),
385                ports: Some(&resolved_ports),
386                ..RenderContext::default()
387            };
388            let resolved_exports = definition
389                .exports
390                .iter()
391                .map(|(key, value)| (key.clone(), render(value, &context)))
392                .collect();
393
394            branch.resources.insert(
395                definition.name.clone(),
396                ResourceBinding {
397                    definition_rev: definition.definition_rev.clone(),
398                    resolved_ports: resolved_ports.clone(),
399                    resolved_exports,
400                    status: ResourceStatus::Pending,
401                },
402            );
403
404            let blocked_by = self.blocked_dependencies(branch, definition);
405
406            // Prepare runs with the bindings made so far, so dependents see
407            // their dependencies' exports. Failed dependencies block
408            // dependents; the instance still spawns so logs can be inspected.
409            let mut captured_names = Vec::new();
410            let (status, prepare) = if !blocked_by.is_empty() {
411                if let Some(binding) = branch.resources.get_mut(&definition.name) {
412                    binding.status = ResourceStatus::Blocked;
413                }
414                (ResourceStatus::Blocked, None)
415            } else {
416                match definition.actions.get("prepare") {
417                    Some(action) if action.command.is_some() && !action.long_running => {
418                        let log = self
419                            .store
420                            .action_log_path(&branch.slug, &format!("{}.prepare", definition.name));
421                        let (code, captured) =
422                            self.run_one_shot(branch, definition, action, &log)?;
423                        captured_names = captured.keys().cloned().collect();
424                        Self::apply_captures(branch, &definition.name, captured);
425                        let status = if code == 0 {
426                            ResourceStatus::Ready
427                        } else {
428                            ResourceStatus::Failed
429                        };
430                        if let Some(binding) = branch.resources.get_mut(&definition.name) {
431                            binding.status = status;
432                        }
433                        (status, Some((code == 0, log)))
434                    }
435                    _ => {
436                        if let Some(binding) = branch.resources.get_mut(&definition.name) {
437                            binding.status = ResourceStatus::Ready;
438                        }
439                        (ResourceStatus::Ready, None)
440                    }
441                }
442            };
443
444            outcomes.push(ResourceBindOutcome {
445                name: definition.name.clone(),
446                ports: resolved_ports,
447                status,
448                prepare,
449                blocked_by,
450                captured: captured_names,
451            });
452        }
453        branch.updated_at = Utc::now();
454        Ok(outcomes)
455    }
456
457    /// Run `<resource>.<action>` for an instance.
458    pub fn run_action(&self, instance: &str, spec: &str) -> Result<ActionOutcome> {
459        let (resource_name, action_name) = spec.split_once('.').ok_or_else(|| {
460            NewgitError::Unsupported(format!("`{spec}` is not of the form <resource>.<action>"))
461        })?;
462        let mut branch = self.store.find_branch(instance)?;
463        self.require_workspace(&branch)?;
464        let definition = self.resource_definition(resource_name)?;
465        let action =
466            definition
467                .actions
468                .get(action_name)
469                .ok_or_else(|| NewgitError::UnknownAction {
470                    resource: resource_name.to_owned(),
471                    action: action_name.to_owned(),
472                })?;
473        let supervisor = self.supervisor(&branch);
474
475        // Signal-only action (e.g. stop): signal the supervised process.
476        if action.command.is_none() {
477            let signal = action
478                .signal
479                .clone()
480                .unwrap_or_else(|| definition.stop_signal());
481            return Ok(ActionOutcome::Stopped(
482                supervisor.stop(&definition.name, &signal)?,
483            ));
484        }
485
486        let blocked_by = self.blocked_dependencies(&branch, definition);
487        if !blocked_by.is_empty() {
488            if let Some(binding) = branch.resources.get_mut(&definition.name) {
489                binding.status = ResourceStatus::Blocked;
490                branch.updated_at = Utc::now();
491                self.store.save_branch_record(&branch)?;
492            }
493            return Err(NewgitError::Unsupported(format!(
494                "resource `{resource_name}` is blocked by failed dependency/dependencies: {}",
495                blocked_by.join(", ")
496            )));
497        }
498
499        let log = self
500            .store
501            .action_log_path(&branch.slug, &format!("{}.{action_name}", definition.name));
502
503        if action.long_running {
504            let command = self.rendered_command(&branch, definition, action)?;
505            let env = self.assemble_env(&branch)?;
506            let pid = supervisor.start(
507                &definition.name,
508                &command,
509                &branch.workspace_path,
510                &env,
511                &log,
512            )?;
513            return Ok(ActionOutcome::Started { pid, log });
514        }
515
516        let (code, captured) = self.run_one_shot(&branch, definition, action, &log)?;
517        let mut dirty = Self::apply_captures(&mut branch, &definition.name, captured);
518        if action_name == "prepare"
519            && let Some(binding) = branch.resources.get_mut(&definition.name)
520        {
521            binding.status = if code == 0 {
522                ResourceStatus::Ready
523            } else {
524                ResourceStatus::Failed
525            };
526            dirty = true;
527        }
528        if dirty {
529            branch.updated_at = Utc::now();
530            self.store.save_branch_record(&branch)?;
531        }
532        Ok(ActionOutcome::Ran { code, log })
533    }
534
535    /// Run an arbitrary command inside the instance with the full export
536    /// environment loaded. Returns the exit code.
537    pub fn run_command(
538        &self,
539        instance: &str,
540        command_line: &[String],
541    ) -> Result<(i32, Utf8PathBuf)> {
542        let branch = self.store.find_branch(instance)?;
543        self.require_workspace(&branch)?;
544        let env = self.assemble_env(&branch)?;
545        let log = self.store.action_log_path(&branch.slug, "run");
546        let code = run_foreground(command_line, &branch.workspace_path, &env, &log)?;
547        Ok((code, log))
548    }
549
550    /// Run a one-shot action, returning its exit code and whatever values it
551    /// declared in `captures`. An action with captures runs captured (its
552    /// output reaches the log but not the terminal), because newgit has to
553    /// read stdout to find the handle the command just minted.
554    fn run_one_shot(
555        &self,
556        branch: &BranchInstance,
557        definition: &ResourceDefinition,
558        action: &crate::resource::ActionSpec,
559        log: &Utf8Path,
560    ) -> Result<(i32, BTreeMap<String, String>)> {
561        let command = self.rendered_command(branch, definition, action)?;
562        let env = self.assemble_env(branch)?;
563
564        if action.captures.is_empty() {
565            let code = run_foreground(
566                &["sh".to_owned(), "-c".to_owned(), command],
567                &branch.workspace_path,
568                &env,
569                log,
570            )?;
571            return Ok((code, BTreeMap::new()));
572        }
573
574        let (code, stdout) = run_captured(&command, &branch.workspace_path, &env, log)?;
575        Ok((code, parse_captures(&stdout, &action.captures)))
576    }
577
578    /// Merge values an action captured into the resource's binding exports,
579    /// so later commands, hooks, and `newgit run` all see the handle. The
580    /// binding record is the single source of truth for a resource instance,
581    /// including the parts another system named.
582    fn apply_captures(
583        branch: &mut BranchInstance,
584        resource: &str,
585        captured: BTreeMap<String, String>,
586    ) -> bool {
587        if captured.is_empty() {
588            return false;
589        }
590        let Some(binding) = branch.resources.get_mut(resource) else {
591            return false;
592        };
593        binding.resolved_exports.extend(captured);
594        true
595    }
596
597    fn rendered_command(
598        &self,
599        branch: &BranchInstance,
600        definition: &ResourceDefinition,
601        action: &crate::resource::ActionSpec,
602    ) -> Result<String> {
603        let command = action.command.clone().ok_or_else(|| {
604            NewgitError::Unsupported(format!(
605                "resource `{}` action has no command",
606                definition.name
607            ))
608        })?;
609        let context = RenderContext {
610            branch_name: &branch.name,
611            branch_slug: &branch.slug,
612            workspace: branch.workspace_path.as_str(),
613            ports: branch
614                .resources
615                .get(&definition.name)
616                .map(|binding| &binding.resolved_ports),
617            ..RenderContext::default()
618        };
619        Ok(render(&command, &context))
620    }
621
622    /// The layered environment `newgit run` and actions see. Later layers
623    /// win: resource exports in dependency order → port env vars → newgit
624    /// context vars. Trackers own content; command environment wiring lives
625    /// outside the tracker primitive.
626    pub fn assemble_env(&self, branch: &BranchInstance) -> Result<Vec<(String, String)>> {
627        let mut env: BTreeMap<String, String> = BTreeMap::new();
628
629        // Layer 1: resource exports, dependency order (dependents win).
630        for name in &self.resource_order {
631            if let Some(binding) = branch.resources.get(name) {
632                for (key, value) in &binding.resolved_exports {
633                    env.insert(key.clone(), value.clone());
634                }
635            }
636        }
637
638        // Layer 2: port env vars.
639        for name in &self.resource_order {
640            let Some(binding) = branch.resources.get(name) else {
641                continue;
642            };
643            let Ok(definition) = self.resource_definition(name) else {
644                continue;
645            };
646            for (port_name, request) in &definition.ports {
647                if let (Some(env_name), Some(port)) =
648                    (&request.env, binding.resolved_ports.get(port_name))
649                {
650                    env.insert(env_name.clone(), port.to_string());
651                }
652            }
653        }
654
655        // Layer 3: context vars.
656        env.insert("NEWGIT_BRANCH".to_owned(), branch.name.clone());
657        env.insert(
658            "NEWGIT_WORKSPACE".to_owned(),
659            branch.workspace_path.to_string(),
660        );
661
662        Ok(env.into_iter().collect())
663    }
664
665    pub fn add_resource(&self, name: &str, template_name: &str) -> Result<AddResourceOutcome> {
666        validate_name(name)?;
667        let template = resource_template(template_name)
668            .ok_or_else(|| NewgitError::UnknownTemplate(template_name.to_owned()))?;
669        let path = self.store.write_resource_file(name, template.contents)?;
670
671        // Companions the template depends on, created only when absent so an
672        // existing definition is never overwritten.
673        let mut companions_created = Vec::new();
674        for companion in template.companions {
675            let companion_path = self
676                .store
677                .paths()
678                .resources
679                .join(format!("{}.toml", companion.name));
680            if !companion_path.exists() {
681                companions_created.push(
682                    self.store
683                        .write_resource_file(companion.name, companion.contents)?,
684                );
685            }
686        }
687
688        // Lanes the template deposits into. A checkpoint whose `into_tracker`
689        // names a tracker that does not exist fails at checkpoint time, so a
690        // template that deposits has to bring its lane with it.
691        let mut trackers_created = Vec::new();
692        for companion in template.companion_trackers {
693            let tracker_path = self
694                .store
695                .paths()
696                .trackers
697                .join(format!("{}.toml", companion.name));
698            if !tracker_path.exists() {
699                trackers_created.push(
700                    self.create_tracker(
701                        companion.name,
702                        companion.audience,
703                        Storage::Local,
704                        companion.merge_with_source,
705                    )?
706                    .path,
707                );
708            }
709        }
710
711        Ok(AddResourceOutcome {
712            path,
713            companions_created,
714            trackers_created,
715        })
716    }
717
718    pub fn resource_definitions(&self) -> &[ResourceDefinition] {
719        &self.resources
720    }
721
722    fn resource_definition(&self, name: &str) -> Result<&ResourceDefinition> {
723        self.resources
724            .iter()
725            .find(|definition| definition.name == name)
726            .ok_or_else(|| NewgitError::UnknownResource(name.to_owned()))
727    }
728
729    fn supervisor(&self, branch: &BranchInstance) -> Supervisor {
730        Supervisor::new(self.store.instance_state_dir(&branch.slug))
731    }
732
733    fn blocked_dependencies(
734        &self,
735        branch: &BranchInstance,
736        definition: &ResourceDefinition,
737    ) -> Vec<String> {
738        definition
739            .depends_on
740            .iter()
741            .filter_map(|dependency| {
742                branch.resources.get(dependency).and_then(|binding| {
743                    (binding.status != ResourceStatus::Ready).then(|| dependency.clone())
744                })
745            })
746            .collect()
747    }
748
749    /// Bind a tracker into an instance workspace. If the lane has captured
750    /// content, project the lane head; otherwise the tracker starts empty.
751    /// `refresh` allows rebinding an already-bound tracker.
752    fn bind_tracker(
753        &self,
754        branch: &mut BranchInstance,
755        definition: &TrackerDefinition,
756        refresh: bool,
757    ) -> Result<TrackerBindOutcome> {
758        let lane = self.lane(&definition.name);
759        let workspace = branch.workspace_path.clone();
760
761        // Safety net when re-materializing over existing content.
762        if refresh && !definition.paths.is_empty() {
763            let existing = collect_owned_files(&workspace, definition)?;
764            if !existing.is_empty() {
765                lane.capture(&workspace, definition)?;
766            }
767        }
768
769        let (origin, content_rev, files) = match lane.latest() {
770            Some(rev) => {
771                let files = lane.restore(&workspace, definition, &rev)?;
772                (BindOrigin::LaneHead, Some(rev), files)
773            }
774            None => (BindOrigin::Nothing, None, 0),
775        };
776
777        branch.trackers.insert(
778            definition.name.clone(),
779            TrackerBinding {
780                definition_rev: definition.definition_rev.clone(),
781                content_rev: content_rev.clone(),
782            },
783        );
784        branch.updated_at = Utc::now();
785
786        Ok(TrackerBindOutcome {
787            name: definition.name.clone(),
788            content_rev,
789            files,
790            origin,
791        })
792    }
793
794    pub fn capture_tracker(&self, instance: &str, tracker: &str) -> Result<CaptureReport> {
795        let mut branch = self.store.find_branch(instance)?;
796        let definition = self.definition(tracker)?;
797        self.require_workspace(&branch)?;
798
799        let lane = self.lane(&definition.name);
800        let capture = lane.capture(&branch.workspace_path, definition)?;
801
802        let previous = branch
803            .trackers
804            .get(&definition.name)
805            .and_then(|binding| binding.content_rev.clone());
806        let changed = previous.as_deref() != Some(capture.rev.as_str());
807        branch.trackers.insert(
808            definition.name.clone(),
809            TrackerBinding {
810                definition_rev: definition.definition_rev.clone(),
811                content_rev: Some(capture.rev.clone()),
812            },
813        );
814        branch.updated_at = Utc::now();
815        self.store.save_branch_record(&branch)?;
816
817        Ok(CaptureReport {
818            rev: capture.rev,
819            files: capture.files,
820            changed,
821        })
822    }
823
824    pub fn checkout_tracker(
825        &self,
826        instance: &str,
827        tracker: &str,
828        rev: Option<&str>,
829    ) -> Result<RestoreReport> {
830        let mut branch = self.store.find_branch(instance)?;
831        let definition = self.definition(tracker)?;
832        self.require_workspace(&branch)?;
833        let lane = self.lane(&definition.name);
834
835        let target_rev = match rev {
836            Some(rev) => rev.to_owned(),
837            None => branch
838                .trackers
839                .get(&definition.name)
840                .and_then(|binding| binding.content_rev.clone())
841                .ok_or_else(|| {
842                    NewgitError::Unsupported(format!(
843                        "tracker `{}` has no bound content for `{}`; pass --rev",
844                        definition.name, branch.name
845                    ))
846                })?,
847        };
848        if !lane.has_rev(&target_rev) {
849            return Err(NewgitError::NoSnapshot {
850                tracker: definition.name.clone(),
851                rev: target_rev,
852            });
853        }
854
855        // Restoring never loses state: current content is captured first.
856        let safety = lane.capture(&branch.workspace_path, definition)?;
857        let safety_rev = (safety.rev != target_rev).then_some(safety.rev);
858
859        let files = lane.restore(&branch.workspace_path, definition, &target_rev)?;
860
861        branch.trackers.insert(
862            definition.name.clone(),
863            TrackerBinding {
864                definition_rev: definition.definition_rev.clone(),
865                content_rev: Some(target_rev.clone()),
866            },
867        );
868        branch.updated_at = Utc::now();
869        self.store.save_branch_record(&branch)?;
870
871        Ok(RestoreReport {
872            rev: target_rev,
873            files,
874            safety_rev,
875        })
876    }
877
878    /// Pull a tracker's lane head into an existing instance.
879    pub fn pull_tracker(&self, instance: &str, tracker: &str) -> Result<TrackerBindOutcome> {
880        let mut branch = self.store.find_branch(instance)?;
881        let definition = self.definition(tracker)?;
882        self.require_workspace(&branch)?;
883
884        if self.lane(&definition.name).latest().is_none() {
885            return Err(NewgitError::Unsupported(format!(
886                "tracker `{}` has no merged content to pull",
887                definition.name
888            )));
889        }
890
891        let outcome = self.bind_tracker(&mut branch, definition, true)?;
892        self.store.save_branch_record(&branch)?;
893        Ok(outcome)
894    }
895
896    /// Promote this branch instance's bound tracker revision to the lane head.
897    pub fn merge_tracker(&self, instance: &str, tracker: &str) -> Result<MergeTrackerOutcome> {
898        let branch = self.store.find_branch(instance)?;
899        let definition = self.definition(tracker)?;
900        let rev = branch
901            .trackers
902            .get(&definition.name)
903            .and_then(|binding| binding.content_rev.clone())
904            .ok_or_else(|| {
905                NewgitError::Unsupported(format!(
906                    "tracker `{}` has no captured content for `{}`; run `newgit tracker capture {}` first",
907                    definition.name, branch.name, definition.name
908                ))
909            })?;
910        let lane = self.lane(&definition.name);
911        if !lane.has_rev(&rev) {
912            return Err(NewgitError::NoSnapshot {
913                tracker: definition.name.clone(),
914                rev,
915            });
916        }
917        lane.set_latest(&rev)?;
918        Ok(MergeTrackerOutcome {
919            tracker: definition.name.clone(),
920            rev,
921        })
922    }
923
924    pub fn create_tracker(
925        &self,
926        name: &str,
927        audience: &str,
928        storage: Storage,
929        merge_with_source: bool,
930    ) -> Result<AddTrackerOutcome> {
931        validate_name(name)?;
932        let definition = TrackerDefinition::new(
933            name,
934            audience.to_owned(),
935            storage,
936            merge_with_source,
937            Vec::new(),
938        )?;
939        let path = self.store.create_tracker_definition(&definition)?;
940        Ok(AddTrackerOutcome {
941            path,
942            ignored_patterns: Vec::new(),
943        })
944    }
945
946    pub fn track_paths(&self, tracker: &str, paths: &[Utf8PathBuf]) -> Result<TrackPathsOutcome> {
947        let definition = self.definition_or_load(tracker)?;
948        let updated = definition.with_added_paths(paths)?;
949        validate_disjoint_with_replacement(&self.trackers, &updated)?;
950        let path = self.store.save_tracker_definition(&updated)?;
951
952        let mut patterns = Vec::new();
953        for owned in paths {
954            if !self.source.is_ignored(owned.as_str())? {
955                patterns.push(format!("/{owned}"));
956            }
957        }
958        self.store.append_gitignore(tracker, &patterns)?;
959
960        Ok(TrackPathsOutcome {
961            path,
962            added_paths: paths.to_vec(),
963            ignored_patterns: patterns,
964        })
965    }
966
967    pub fn statuses(&self) -> Result<Vec<InstanceReport>> {
968        let lane_heads: Vec<(String, Option<String>)> = self
969            .trackers
970            .iter()
971            .map(|definition| {
972                (
973                    definition.name.clone(),
974                    self.lane(&definition.name).latest(),
975                )
976            })
977            .collect();
978
979        self.store
980            .load_branches()?
981            .into_iter()
982            .map(|branch| {
983                let workspace_exists = branch.workspace_path.is_dir();
984                let live_rev = workspace_exists
985                    .then(|| GitSource::workspace_short_head(&branch.workspace_path).ok())
986                    .flatten();
987                let trackers = self
988                    .trackers
989                    .iter()
990                    .map(|definition| {
991                        let content_rev = branch
992                            .trackers
993                            .get(&definition.name)
994                            .and_then(|binding| binding.content_rev.clone());
995                        let head = lane_heads
996                            .iter()
997                            .find(|(name, _)| name == &definition.name)
998                            .and_then(|(_, head)| head.clone());
999                        TrackerReport {
1000                            name: definition.name.clone(),
1001                            content_rev,
1002                            lane_head: head,
1003                        }
1004                    })
1005                    .collect();
1006                let supervisor = self.supervisor(&branch);
1007                let resources = self
1008                    .resources
1009                    .iter()
1010                    .map(|definition| {
1011                        let state = match branch.resources.get(&definition.name) {
1012                            None => "—".to_owned(),
1013                            Some(binding) => {
1014                                if supervisor.running_pid(&definition.name).is_some() {
1015                                    "running".to_owned()
1016                                } else if definition.has_long_running_action()
1017                                    && binding.status == ResourceStatus::Ready
1018                                {
1019                                    "stopped".to_owned()
1020                                } else {
1021                                    match binding.status {
1022                                        ResourceStatus::Pending => "pending".to_owned(),
1023                                        ResourceStatus::Ready => "ready".to_owned(),
1024                                        ResourceStatus::Failed => "failed".to_owned(),
1025                                        ResourceStatus::Blocked => "blocked".to_owned(),
1026                                    }
1027                                }
1028                            }
1029                        };
1030                        ResourceReport {
1031                            name: definition.name.clone(),
1032                            state,
1033                        }
1034                    })
1035                    .collect();
1036                Ok(InstanceReport {
1037                    branch,
1038                    workspace_exists,
1039                    live_rev,
1040                    trackers,
1041                    resources,
1042                })
1043            })
1044            .collect()
1045    }
1046
1047    /// Deletes the workspace (plain `rm -rf`; clones have no registration)
1048    /// and archives the binding record. The source branch in the store is
1049    /// kept — removal disposes of the workspace, not the history.
1050    ///
1051    /// Resource cleanup hooks run first, dependents before dependencies and
1052    /// while the workspace still exists. Without that, a resource newgit
1053    /// does not own — a cloud preview, a database — would outlive every
1054    /// trace of the instance that asked for it.
1055    pub fn remove(&self, name: &str, cwd: &Utf8Path) -> Result<RemoveOutcome> {
1056        let branch = self.store.find_branch(name)?;
1057
1058        if cwd.starts_with(&branch.workspace_path) {
1059            return Err(NewgitError::Unsupported(format!(
1060                "the current directory is inside the workspace of `{}`; step out of it before \
1061                 removing",
1062                branch.name
1063            )));
1064        }
1065
1066        // Stop anything still running before the workspace disappears.
1067        let supervisor = self.supervisor(&branch);
1068        for definition in &self.resources {
1069            if supervisor.running_pid(&definition.name).is_some() {
1070                supervisor.stop(&definition.name, &definition.stop_signal())?;
1071            }
1072        }
1073
1074        let hooks = self.run_cleanup_hooks(&branch, false)?;
1075
1076        let state_dir = self.store.instance_state_dir(&branch.slug);
1077        if state_dir.exists() {
1078            std::fs::remove_dir_all(&state_dir)
1079                .map_err(|source| NewgitError::io(state_dir, source))?;
1080        }
1081
1082        RealDirMaterializer.remove(&branch)?;
1083        let archived_record = self.store.archive_branch_record(&branch)?;
1084
1085        Ok(RemoveOutcome {
1086            branch,
1087            archived_record,
1088            hooks,
1089        })
1090    }
1091
1092    /// Run each bound resource's `[cleanup] command`, dependents before
1093    /// dependencies. Ownership decides whether a hook may run at all —
1094    /// `project` and `user` resources are shared beyond this instance, so
1095    /// per-branch teardown leaves them alone even when they define a hook.
1096    fn run_cleanup_hooks(
1097        &self,
1098        branch: &BranchInstance,
1099        dry_run: bool,
1100    ) -> Result<Vec<HookOutcome>> {
1101        // The workspace is usually still here; when cleanup is finishing an
1102        // instance whose workspace is already gone, hooks run from the store
1103        // root so an external teardown can still reach its own API.
1104        let cwd = if branch.workspace_path.is_dir() {
1105            branch.workspace_path.clone()
1106        } else {
1107            self.store.paths().project_root.clone()
1108        };
1109
1110        let mut outcomes = Vec::new();
1111        for name in self.resource_order.iter().rev() {
1112            if !branch.resources.contains_key(name) {
1113                continue;
1114            }
1115            let definition = self.resource_definition(name)?;
1116            let ownership = definition.ownership;
1117
1118            if !may_tear_down(ownership) {
1119                outcomes.push(HookOutcome {
1120                    resource: name.clone(),
1121                    ownership,
1122                    detail: HookDetail::SkippedOwnership,
1123                });
1124                continue;
1125            }
1126
1127            let Some(template) = definition
1128                .cleanup
1129                .as_ref()
1130                .and_then(|spec| spec.command.as_deref())
1131            else {
1132                outcomes.push(HookOutcome {
1133                    resource: name.clone(),
1134                    ownership,
1135                    detail: HookDetail::NoHook,
1136                });
1137                continue;
1138            };
1139
1140            let binding = branch.resources.get(name);
1141            let state_ref = self.checkpointed_state_ref(branch, name)?;
1142            let context = RenderContext {
1143                branch_name: &branch.name,
1144                branch_slug: &branch.slug,
1145                workspace: branch.workspace_path.as_str(),
1146                ports: binding.map(|binding| &binding.resolved_ports),
1147                exports: binding.map(|binding| &binding.resolved_exports),
1148                snapshot_path: None,
1149                state_ref: state_ref.as_deref(),
1150            };
1151            let command = render(template, &context);
1152
1153            if let Some(placeholder) = unresolved_placeholder(&command) {
1154                outcomes.push(HookOutcome {
1155                    resource: name.clone(),
1156                    ownership,
1157                    detail: HookDetail::SkippedUnresolved {
1158                        command: command.clone(),
1159                        placeholder: placeholder.to_owned(),
1160                    },
1161                });
1162                continue;
1163            }
1164
1165            if dry_run {
1166                outcomes.push(HookOutcome {
1167                    resource: name.clone(),
1168                    ownership,
1169                    detail: HookDetail::WouldRun(command),
1170                });
1171                continue;
1172            }
1173
1174            let log = self
1175                .store
1176                .action_log_path(&branch.slug, &format!("{name}.cleanup"));
1177            let env = self.assemble_env(branch)?;
1178            let (code, _) = run_captured(&command, &cwd, &env, &log)?;
1179            outcomes.push(HookOutcome {
1180                resource: name.clone(),
1181                ownership,
1182                detail: HookDetail::Ran {
1183                    command,
1184                    ok: code == 0,
1185                    log,
1186                },
1187            });
1188        }
1189        Ok(outcomes)
1190    }
1191
1192    /// The most recent checkpointed state reference for one resource, which
1193    /// is what a cleanup hook's `{{state_ref}}` means: the handle newgit last
1194    /// recorded. Deposited content resolves to its path, like restore.
1195    fn checkpointed_state_ref(
1196        &self,
1197        branch: &BranchInstance,
1198        resource: &str,
1199    ) -> Result<Option<String>> {
1200        let records = self.checkpoint_log(branch).list()?;
1201        for record in records.iter().rev() {
1202            if let Some(state) = record
1203                .resource_states
1204                .iter()
1205                .find(|state| state.name == resource)
1206            {
1207                let resolved = state
1208                    .state_path
1209                    .as_ref()
1210                    .map(ToString::to_string)
1211                    .or_else(|| state.state_ref.clone());
1212                if resolved.is_some() {
1213                    return Ok(resolved);
1214                }
1215            }
1216        }
1217        Ok(None)
1218    }
1219
1220    /// Garbage collection across everything: finish instances whose
1221    /// workspace is gone, delete unclaimed workspaces and dead process
1222    /// state, and prune lane revs nothing references.
1223    ///
1224    /// `remove` targets one instance; this is the sweep. It never deletes a
1225    /// checkpoint record, and never a lane rev a checkpoint still points at.
1226    pub fn cleanup(&self, dry_run: bool) -> Result<CleanupOutcome> {
1227        let mut outcome = CleanupOutcome {
1228            dry_run,
1229            ..CleanupOutcome::default()
1230        };
1231        let branches = self.store.load_branches()?;
1232
1233        // An instance with no workspace cannot run, checkpoint, or undo, and
1234        // its name stays taken — so finishing the teardown is the only move
1235        // that helps. Its binding record is archived, not deleted.
1236        let (live, stale): (Vec<_>, Vec<_>) = branches
1237            .iter()
1238            .partition(|branch| branch.workspace_path.is_dir());
1239
1240        for branch in &stale {
1241            let hooks = self.run_cleanup_hooks(branch, dry_run)?;
1242            let archived_record = if dry_run {
1243                None
1244            } else {
1245                let state_dir = self.store.instance_state_dir(&branch.slug);
1246                if state_dir.exists() {
1247                    std::fs::remove_dir_all(&state_dir)
1248                        .map_err(|source| NewgitError::io(state_dir, source))?;
1249                }
1250                Some(self.store.archive_branch_record(branch)?)
1251            };
1252            outcome.finalized.push(FinalizedInstance {
1253                name: branch.name.clone(),
1254                workspace: branch.workspace_path.clone(),
1255                hooks,
1256                archived_record,
1257            });
1258        }
1259
1260        // Unclaimed workspace directories: a failed spawn, or a record
1261        // archived while its directory survived.
1262        let workspace_root = self.config.workspace_root(&self.store.paths().project_root);
1263        let (orphans, unrecognized) = orphan_workspaces(&workspace_root, &branches)?;
1264        outcome.warnings.extend(unrecognized);
1265        for orphan in orphans {
1266            if !dry_run {
1267                std::fs::remove_dir_all(&orphan)
1268                    .map_err(|source| NewgitError::io(&orphan, source))?;
1269            }
1270            outcome.orphan_workspaces.push(orphan);
1271        }
1272
1273        // Dead process state: PID files whose group exited, and state
1274        // directories belonging to no live instance.
1275        for branch in &live {
1276            outcome
1277                .dead_state
1278                .extend(self.supervisor(branch).prune_dead_pids(dry_run)?);
1279        }
1280        let live_state_dirs: BTreeSet<Utf8PathBuf> = live
1281            .iter()
1282            .map(|branch| self.store.instance_state_dir(&branch.slug))
1283            .collect();
1284        for state_dir in self.store.state_dirs()? {
1285            if live_state_dirs.contains(&state_dir) {
1286                continue;
1287            }
1288            if !dry_run {
1289                std::fs::remove_dir_all(&state_dir)
1290                    .map_err(|source| NewgitError::io(&state_dir, source))?;
1291            }
1292            outcome.dead_state.push(state_dir);
1293        }
1294
1295        // Lane pruning. Roots come from the records that survive this pass,
1296        // so a dry run reports exactly what a real run would remove.
1297        let surviving: Vec<BranchInstance> = live.into_iter().cloned().collect();
1298        let roots = SnapshotRoots::collect(&self.store, &surviving)?;
1299        for lane_rev in lane_revs(&self.store.paths().snapshots)? {
1300            if !lane_rev.is_staging && roots.contains(&lane_rev.tracker, &lane_rev.rev) {
1301                continue;
1302            }
1303            if !dry_run {
1304                std::fs::remove_dir_all(&lane_rev.path)
1305                    .map_err(|source| NewgitError::io(&lane_rev.path, source))?;
1306            }
1307            outcome.pruned.push(PrunedRev {
1308                tracker: lane_rev.tracker,
1309                rev: lane_rev.rev,
1310                path: lane_rev.path,
1311            });
1312        }
1313        outcome.pinned_by_checkpoints = roots.pinned_only_by_checkpoints().count();
1314
1315        Ok(outcome)
1316    }
1317
1318    /// Write a branch instance's content out as an ordinary Git repository.
1319    ///
1320    /// Tracker audience is the default filter and it fails closed: only
1321    /// `public` lanes ship unless `--include` names a path. This is
1322    /// path-level filtering and nothing more — no hunk privacy, no
1323    /// concealment claim.
1324    pub fn export(
1325        &self,
1326        instance: &str,
1327        destination: &Utf8Path,
1328        filter: &ExportFilter,
1329    ) -> Result<ExportOutcome> {
1330        let branch = self.store.find_branch(instance)?;
1331        self.require_workspace(&branch)?;
1332        prepare_destination(destination)?;
1333
1334        let workspace = branch.workspace_path.clone();
1335        let source_files = GitSource::workspace_tracked_files(&workspace)?;
1336        let plan = export::plan(&workspace, &source_files, &self.trackers, filter)?;
1337
1338        if plan.files.is_empty() {
1339            return Err(NewgitError::Unsupported(format!(
1340                "nothing to export from `{}`: every candidate path was withheld by audience or \
1341                 excluded",
1342                branch.name
1343            )));
1344        }
1345
1346        for file in &plan.files {
1347            copy_file(&workspace.join(&file.path), &destination.join(&file.path))?;
1348        }
1349
1350        let head_rev = GitSource::workspace_head(&workspace)?;
1351        let commit = GitSource::init_export_repo(
1352            destination,
1353            &branch.source_ref,
1354            &format!(
1355                "Export of `{}` at {}",
1356                branch.name,
1357                &head_rev[..8.min(head_rev.len())]
1358            ),
1359        )?;
1360
1361        Ok(ExportOutcome {
1362            destination: destination.to_path_buf(),
1363            branch: branch.source_ref.clone(),
1364            instance: branch.name.clone(),
1365            source_head: head_rev,
1366            commit,
1367            plan,
1368        })
1369    }
1370
1371    /// Record one coherent snapshot across source, trackers, and resources.
1372    pub fn checkpoint(&self, instance: &str, message: Option<&str>) -> Result<CheckpointOutcome> {
1373        let mut branch = self.store.find_branch(instance)?;
1374        self.checkpoint_branch(&mut branch, message, CheckpointReason::Explicit)
1375    }
1376
1377    pub fn list_checkpoints(&self, instance: &str) -> Result<Vec<CheckpointRecord>> {
1378        let branch = self.store.find_branch(instance)?;
1379        self.checkpoint_log(&branch).list()
1380    }
1381
1382    fn checkpoint_branch(
1383        &self,
1384        branch: &mut BranchInstance,
1385        message: Option<&str>,
1386        reason: CheckpointReason,
1387    ) -> Result<CheckpointOutcome> {
1388        self.require_workspace(branch)?;
1389        let mut warnings = Vec::new();
1390        let checkpoint_log = self.checkpoint_log(branch);
1391        let id = checkpoint_log.next_id()?;
1392        let workspace = branch.workspace_path.clone();
1393
1394        // Source: the committed state plus a dangling commit for anything
1395        // uncommitted, fetched into the store. The store learns about
1396        // workspace commits only here — checkpoint is the blessing boundary,
1397        // and a checkpoint protects the worktree as it stands, not just what
1398        // the agent remembered to commit.
1399        let head_rev = GitSource::workspace_head(&workspace)?;
1400        let dirty_rev = GitSource::workspace_dirty_commit(
1401            &workspace,
1402            &format!("newgit {id}: uncommitted state of `{}`", branch.name),
1403        )?;
1404        let tip = dirty_rev.clone().unwrap_or_else(|| head_rev.clone());
1405        let workspace_ref = format!("refs/newgit/checkpoints/{id}");
1406        let store_ref = format!("refs/newgit/checkpoints/{}/{id}", branch.slug);
1407        GitSource::workspace_update_ref(&workspace, &workspace_ref, &tip)?;
1408        let fetched = self
1409            .source
1410            .fetch_ref(&workspace, &workspace_ref, &store_ref);
1411        GitSource::workspace_delete_ref(&workspace, &workspace_ref)?;
1412        fetched?;
1413        self.bless_store_branch(branch, &head_rev, None, &mut warnings)?;
1414
1415        // Resources, dependents first: a dependent's state may be derived
1416        // from its dependency, so it is captured before the dependency moves.
1417        let mut resource_states = Vec::new();
1418        let mut deposits: Vec<(String, String)> = Vec::new();
1419        for name in self.resource_order.iter().rev() {
1420            let Some(binding) = branch.resources.get(name) else {
1421                continue;
1422            };
1423            let definition = self.resource_definition(name)?;
1424            let was_running = self.supervisor(branch).running_pid(name).is_some();
1425            let captured = self.checkpoint_resource(branch, definition)?;
1426            if let Some(deposit) = captured.deposit {
1427                deposits.push(deposit);
1428            }
1429            resource_states.push(ResourceState {
1430                name: name.clone(),
1431                definition_rev: definition.definition_rev.clone(),
1432                mode: captured.mode,
1433                state_ref: captured.state_ref,
1434                state_path: captured.state_path,
1435                was_running,
1436                resolved_ports: binding.resolved_ports.clone(),
1437                resolved_exports: binding.resolved_exports.clone(),
1438            });
1439        }
1440        // Recorded in dependency order for readability.
1441        resource_states.reverse();
1442        for (tracker, rev) in deposits {
1443            let definition = self.definition(&tracker)?;
1444            branch.trackers.insert(
1445                tracker,
1446                TrackerBinding {
1447                    definition_rev: definition.definition_rev.clone(),
1448                    content_rev: Some(rev),
1449                },
1450            );
1451        }
1452
1453        // Trackers: capture every owned path (dedupes in the lane).
1454        // Deposit-only lanes record whatever rev the deposit or an earlier
1455        // capture bound.
1456        let mut tracker_states = Vec::new();
1457        for definition in &self.trackers {
1458            let content_rev = if definition.paths.is_empty() {
1459                branch
1460                    .trackers
1461                    .get(&definition.name)
1462                    .and_then(|binding| binding.content_rev.clone())
1463            } else {
1464                Some(
1465                    self.lane(&definition.name)
1466                        .capture(&workspace, definition)?
1467                        .rev,
1468                )
1469            };
1470            branch.trackers.insert(
1471                definition.name.clone(),
1472                TrackerBinding {
1473                    definition_rev: definition.definition_rev.clone(),
1474                    content_rev: content_rev.clone(),
1475                },
1476            );
1477            tracker_states.push(TrackerState {
1478                name: definition.name.clone(),
1479                definition_rev: definition.definition_rev.clone(),
1480                content_rev,
1481            });
1482        }
1483
1484        let record = CheckpointRecord {
1485            id,
1486            branch: branch.name.clone(),
1487            created_at: Utc::now(),
1488            message: message.map(ToOwned::to_owned),
1489            reason,
1490            source: SourceState {
1491                head_rev,
1492                dirty_rev,
1493                store_ref,
1494            },
1495            tracker_states,
1496            resource_states,
1497        };
1498        let record_path = checkpoint_log.save(&record)?;
1499        branch.updated_at = Utc::now();
1500        self.store.save_branch_record(branch)?;
1501
1502        Ok(CheckpointOutcome {
1503            record,
1504            record_path,
1505            warnings,
1506        })
1507    }
1508
1509    fn checkpoint_resource(
1510        &self,
1511        branch: &BranchInstance,
1512        definition: &ResourceDefinition,
1513    ) -> Result<CapturedResource> {
1514        let Some(spec) = &definition.checkpoint else {
1515            return Ok(CapturedResource::none());
1516        };
1517        let binding = branch.resources.get(&definition.name);
1518
1519        match spec.mode {
1520            CheckpointMode::None => Ok(CapturedResource::none()),
1521            CheckpointMode::Hash => {
1522                let files = collect_files(&branch.workspace_path, &spec.paths)?;
1523                let rev = content_rev(&files)?;
1524                Ok(CapturedResource {
1525                    mode: "hash".to_owned(),
1526                    state_ref: Some(format!("hash:{rev}")),
1527                    state_path: None,
1528                    deposit: None,
1529                })
1530            }
1531            CheckpointMode::Command => {
1532                let template = spec.command.as_deref().expect("validated at parse time");
1533
1534                // Checked here, not at manager open: erroring at open would
1535                // make `newgit tracker create <missing>` — the fix — fail too.
1536                if let Some(tracker) = &spec.into_tracker
1537                    && !self.trackers.iter().any(|t| &t.name == tracker)
1538                {
1539                    return Err(NewgitError::InvalidDefinition {
1540                        tracker: definition.name.clone(),
1541                        reason: format!(
1542                            "checkpoint `into_tracker = \"{tracker}\"` names a tracker that is \
1543                             not defined; create it with `newgit tracker create {tracker}`"
1544                        ),
1545                    });
1546                }
1547
1548                // `into_tracker` commands write into a staging dir that is
1549                // deposited into the lane afterwards — the one seam between
1550                // resources and trackers.
1551                let staging = spec
1552                    .into_tracker
1553                    .as_ref()
1554                    .map(|_| {
1555                        tempfile::tempdir()
1556                            .map_err(|source| NewgitError::io(&branch.workspace_path, source))
1557                    })
1558                    .transpose()?;
1559                let staging_path = staging
1560                    .as_ref()
1561                    .map(|dir| {
1562                        Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
1563                            .map_err(|path| NewgitError::NonUtf8Path(path.display().to_string()))
1564                    })
1565                    .transpose()?;
1566
1567                let context = RenderContext {
1568                    branch_name: &branch.name,
1569                    branch_slug: &branch.slug,
1570                    workspace: branch.workspace_path.as_str(),
1571                    ports: binding.map(|binding| &binding.resolved_ports),
1572                    exports: binding.map(|binding| &binding.resolved_exports),
1573                    snapshot_path: staging_path.as_deref().map(Utf8Path::as_str),
1574                    state_ref: None,
1575                };
1576                let command = render(template, &context);
1577                let log = self
1578                    .store
1579                    .action_log_path(&branch.slug, &format!("{}.checkpoint", definition.name));
1580                let env = self.assemble_env(branch)?;
1581                let (code, stdout) = run_captured(&command, &branch.workspace_path, &env, &log)?;
1582                if code != 0 {
1583                    return Err(NewgitError::CheckpointCommandFailed {
1584                        resource: definition.name.clone(),
1585                        code,
1586                        log,
1587                    });
1588                }
1589
1590                match (&spec.into_tracker, &staging_path) {
1591                    (Some(tracker), Some(staging_path)) => {
1592                        let lane = self.lane(tracker);
1593                        let deposit = lane.deposit(staging_path)?;
1594                        // A path the command echoed under {{snapshot.path}}
1595                        // maps to the same relative location inside the lane.
1596                        let state_path = Utf8Path::new(&stdout)
1597                            .strip_prefix(staging_path)
1598                            .map(|relative| lane.rev_path(&deposit.rev).join(relative))
1599                            .unwrap_or_else(|_| lane.rev_path(&deposit.rev));
1600                        Ok(CapturedResource {
1601                            mode: "command".to_owned(),
1602                            state_ref: Some(format!("tracker:{tracker}@{}", deposit.rev)),
1603                            state_path: Some(state_path),
1604                            deposit: Some((tracker.clone(), deposit.rev)),
1605                        })
1606                    }
1607                    _ => Ok(CapturedResource {
1608                        mode: "command".to_owned(),
1609                        state_ref: (!stdout.is_empty()).then_some(stdout),
1610                        state_path: None,
1611                        deposit: None,
1612                    }),
1613                }
1614            }
1615            CheckpointMode::External => {
1616                let template = spec.state_ref.as_deref().expect("validated at parse time");
1617                let context = RenderContext {
1618                    branch_name: &branch.name,
1619                    branch_slug: &branch.slug,
1620                    workspace: branch.workspace_path.as_str(),
1621                    ports: binding.map(|binding| &binding.resolved_ports),
1622                    exports: binding.map(|binding| &binding.resolved_exports),
1623                    ..RenderContext::default()
1624                };
1625                Ok(CapturedResource {
1626                    mode: "external".to_owned(),
1627                    state_ref: Some(render(template, &context)),
1628                    state_path: None,
1629                    deposit: None,
1630                })
1631            }
1632        }
1633    }
1634
1635    /// Restore the branch instance to a checkpoint — the latest, unless
1636    /// `to` names one. The current state is checkpointed first, so undo is
1637    /// always undoable and running it twice is redo.
1638    pub fn undo(&self, instance: &str, to: Option<&str>) -> Result<UndoOutcome> {
1639        let mut branch = self.store.find_branch(instance)?;
1640        self.require_workspace(&branch)?;
1641        let checkpoint_log = self.checkpoint_log(&branch);
1642        let restored = match to {
1643            Some(id) => checkpoint_log.load(id)?,
1644            None => checkpoint_log.latest()?,
1645        };
1646
1647        let safety = self.checkpoint_branch(
1648            &mut branch,
1649            Some(&format!("state before undo to {}", restored.id)),
1650            CheckpointReason::BeforeUndo,
1651        )?;
1652        let mut warnings = safety.warnings.clone();
1653
1654        // Nothing may keep running while content changes underneath it.
1655        let supervisor = self.supervisor(&branch);
1656        for definition in &self.resources {
1657            if supervisor.running_pid(&definition.name).is_some() {
1658                supervisor.stop(&definition.name, &definition.stop_signal())?;
1659            }
1660        }
1661
1662        // Source: content back exactly, uncommitted state uncommitted again.
1663        let workspace = branch.workspace_path.clone();
1664        GitSource::workspace_fetch_ref(&workspace, self.source.root(), &restored.source.store_ref)?;
1665        GitSource::workspace_restore_to(
1666            &workspace,
1667            &restored.source.head_rev,
1668            restored.source.dirty_rev.as_deref(),
1669        )?;
1670        // The pre-undo tip stays reachable from the safety checkpoint's ref,
1671        // so moving the branch back to it is expected, not divergence.
1672        self.bless_store_branch(
1673            &mut branch,
1674            &restored.source.head_rev,
1675            Some(&safety.record.source.head_rev),
1676            &mut warnings,
1677        )?;
1678
1679        // Trackers: plain content, restored exactly; should not partially
1680        // fail in interesting ways, so failures here are hard errors.
1681        let mut trackers = Vec::new();
1682        for state in &restored.tracker_states {
1683            let Some(definition) = self
1684                .trackers
1685                .iter()
1686                .find(|definition| definition.name == state.name)
1687            else {
1688                warnings.push(format!(
1689                    "tracker `{}` from the checkpoint is no longer defined; its content was \
1690                     not restored",
1691                    state.name
1692                ));
1693                continue;
1694            };
1695            if definition.definition_rev != state.definition_rev {
1696                warnings.push(format!(
1697                    "tracker `{}` definition changed since the checkpoint; content was \
1698                     restored against the current definition",
1699                    state.name
1700                ));
1701            }
1702            let files = match &state.content_rev {
1703                Some(rev) => self
1704                    .lane(&definition.name)
1705                    .restore(&workspace, definition, rev)?,
1706                None => {
1707                    clear_owned_paths(&workspace, definition)?;
1708                    0
1709                }
1710            };
1711            branch.trackers.insert(
1712                state.name.clone(),
1713                TrackerBinding {
1714                    definition_rev: definition.definition_rev.clone(),
1715                    content_rev: state.content_rev.clone(),
1716                },
1717            );
1718            trackers.push(UndoTrackerOutcome {
1719                name: state.name.clone(),
1720                rev: state.content_rev.clone(),
1721                files,
1722            });
1723        }
1724
1725        // Resources: dependencies before dependents, restarting what was
1726        // running. Failures are collected into a recovery record, not fatal.
1727        let mut resources = Vec::new();
1728        let mut failures: Vec<RestoreFailure> = Vec::new();
1729        for name in &self.resource_order {
1730            let Some(state) = restored
1731                .resource_states
1732                .iter()
1733                .find(|state| &state.name == name)
1734            else {
1735                continue;
1736            };
1737            if !branch.resources.contains_key(name) {
1738                continue;
1739            }
1740            let definition = self.resource_definition(name)?;
1741            if definition.definition_rev != state.definition_rev {
1742                warnings.push(format!(
1743                    "resource `{name}` definition changed since the checkpoint; restored with \
1744                     the current definition"
1745                ));
1746            }
1747
1748            let (mut action_label, ok) =
1749                self.restore_resource(&mut branch, definition, state, &mut failures)?;
1750            if let Some(binding) = branch.resources.get_mut(name) {
1751                binding.status = if ok {
1752                    ResourceStatus::Ready
1753                } else {
1754                    ResourceStatus::Failed
1755                };
1756            }
1757
1758            if ok && state.was_running {
1759                match self.restart_long_running(&branch, definition) {
1760                    Ok(true) => action_label.push_str(" + restarted"),
1761                    Ok(false) => warnings.push(format!(
1762                        "resource `{name}` was running at checkpoint time but has no \
1763                         long-running action to restart"
1764                    )),
1765                    Err(error) => failures.push(RestoreFailure {
1766                        resource: name.clone(),
1767                        detail: format!("restart failed: {error}"),
1768                        log: None,
1769                        retry_with: format!("newgit action {name}.start {}", branch.name),
1770                    }),
1771                }
1772            }
1773            resources.push(UndoResourceOutcome {
1774                name: name.clone(),
1775                action: action_label,
1776                ok,
1777            });
1778        }
1779
1780        let recovery_record = if failures.is_empty() {
1781            None
1782        } else {
1783            for failure in &failures {
1784                if let Some(binding) = branch.resources.get_mut(&failure.resource) {
1785                    binding.status = ResourceStatus::Failed;
1786                }
1787            }
1788            Some(checkpoint_log.save_recovery(&RecoveryRecord {
1789                checkpoint: restored.id.clone(),
1790                branch: branch.name.clone(),
1791                created_at: Utc::now(),
1792                failures,
1793            })?)
1794        };
1795
1796        branch.updated_at = Utc::now();
1797        self.store.save_branch_record(&branch)?;
1798
1799        Ok(UndoOutcome {
1800            restored,
1801            safety: safety.record,
1802            trackers,
1803            resources,
1804            recovery_record,
1805            warnings,
1806        })
1807    }
1808
1809    /// `branch` is mutable because a recompute restore re-runs `prepare`,
1810    /// which may `capture` a fresh handle — a restored resource must not
1811    /// keep publishing the pre-undo one.
1812    fn restore_resource(
1813        &self,
1814        branch: &mut BranchInstance,
1815        definition: &ResourceDefinition,
1816        state: &ResourceState,
1817        failures: &mut Vec<RestoreFailure>,
1818    ) -> Result<(String, bool)> {
1819        let Some(spec) = &definition.restore else {
1820            return Ok(("none".to_owned(), true));
1821        };
1822        match spec.mode {
1823            RestoreMode::None => Ok(("none".to_owned(), true)),
1824            RestoreMode::External => Ok(("external (no-op)".to_owned(), true)),
1825            RestoreMode::Recompute => {
1826                let action_name = spec.recompute_action();
1827                let action = definition.actions.get(action_name).ok_or_else(|| {
1828                    NewgitError::UnknownAction {
1829                        resource: definition.name.clone(),
1830                        action: action_name.to_owned(),
1831                    }
1832                })?;
1833                let log = self
1834                    .store
1835                    .action_log_path(&branch.slug, &format!("{}.restore", definition.name));
1836                let (code, captured) = self.run_one_shot(branch, definition, action, &log)?;
1837                Self::apply_captures(branch, &definition.name, captured);
1838                let ok = code == 0;
1839                if !ok {
1840                    failures.push(RestoreFailure {
1841                        resource: definition.name.clone(),
1842                        detail: format!("recompute action `{action_name}` exited with {code}"),
1843                        log: Some(log),
1844                        retry_with: format!(
1845                            "newgit action {}.{action_name} {}",
1846                            definition.name, branch.name
1847                        ),
1848                    });
1849                }
1850                Ok((format!("recompute({action_name})"), ok))
1851            }
1852            RestoreMode::Command => {
1853                let template = spec.command.as_deref().expect("validated at parse time");
1854                let binding = branch.resources.get(&definition.name);
1855                let state_ref = state
1856                    .state_path
1857                    .as_ref()
1858                    .map(ToString::to_string)
1859                    .or_else(|| state.state_ref.clone());
1860                let context = RenderContext {
1861                    branch_name: &branch.name,
1862                    branch_slug: &branch.slug,
1863                    workspace: branch.workspace_path.as_str(),
1864                    ports: binding.map(|binding| &binding.resolved_ports),
1865                    exports: binding.map(|binding| &binding.resolved_exports),
1866                    snapshot_path: None,
1867                    state_ref: state_ref.as_deref(),
1868                };
1869                let command = render(template, &context);
1870                let log = self
1871                    .store
1872                    .action_log_path(&branch.slug, &format!("{}.restore", definition.name));
1873                let env = self.assemble_env(branch)?;
1874                let (code, _) = run_captured(&command, &branch.workspace_path, &env, &log)?;
1875                let ok = code == 0;
1876                if !ok {
1877                    failures.push(RestoreFailure {
1878                        resource: definition.name.clone(),
1879                        detail: format!("restore command exited with {code}"),
1880                        log: Some(log),
1881                        retry_with: "repair the resource, then `newgit undo` again".to_owned(),
1882                    });
1883                }
1884                Ok(("command".to_owned(), ok))
1885            }
1886        }
1887    }
1888
1889    /// Start the definition's long-running action again after an undo.
1890    /// `Ok(false)` when the definition has none.
1891    fn restart_long_running(
1892        &self,
1893        branch: &BranchInstance,
1894        definition: &ResourceDefinition,
1895    ) -> Result<bool> {
1896        let Some((action_name, action)) = definition
1897            .actions
1898            .iter()
1899            .find(|(_, action)| action.long_running)
1900        else {
1901            return Ok(false);
1902        };
1903        let log = self
1904            .store
1905            .action_log_path(&branch.slug, &format!("{}.{action_name}", definition.name));
1906        let command = self.rendered_command(branch, definition, action)?;
1907        let env = self.assemble_env(branch)?;
1908        self.supervisor(branch).start(
1909            &definition.name,
1910            &command,
1911            &branch.workspace_path,
1912            &env,
1913            &log,
1914        )?;
1915        Ok(true)
1916    }
1917
1918    /// Point the store's branch ref at the checkpointed head. The branch is
1919    /// owned by this instance — divergence is policy, not mechanism — so a
1920    /// store-side advance is warned about loudly, never hard-refused.
1921    /// `expected_old` silences the warning when the ref is knowingly moved
1922    /// backwards from a rev a checkpoint ref keeps alive (undo).
1923    fn bless_store_branch(
1924        &self,
1925        branch: &mut BranchInstance,
1926        head_rev: &str,
1927        expected_old: Option<&str>,
1928        warnings: &mut Vec<String>,
1929    ) -> Result<()> {
1930        let branch_ref = format!("refs/heads/{}", branch.source_ref);
1931        if let Some(old) = self.source.ref_rev(&branch_ref)
1932            && old != head_rev
1933            && expected_old != Some(old.as_str())
1934            && !self.source.is_ancestor(&old, head_rev)?
1935        {
1936            warnings.push(format!(
1937                "store branch `{}` had commits this workspace does not (was at {}); it now \
1938                 points at {} — the old commits remain in the store repository but no branch \
1939                 ref reaches them",
1940                branch.source_ref,
1941                &old[..8.min(old.len())],
1942                &head_rev[..8.min(head_rev.len())]
1943            ));
1944        }
1945        self.source.update_ref(&branch_ref, head_rev)?;
1946        branch.source_rev = head_rev.to_owned();
1947        Ok(())
1948    }
1949
1950    fn checkpoint_log(&self, branch: &BranchInstance) -> CheckpointLog {
1951        CheckpointLog::new(self.store.checkpoint_dir(&branch.slug), &branch.name)
1952    }
1953
1954    fn definition(&self, tracker: &str) -> Result<&TrackerDefinition> {
1955        self.trackers
1956            .iter()
1957            .find(|definition| definition.name == tracker)
1958            .ok_or_else(|| NewgitError::UnknownTracker(tracker.to_owned()))
1959    }
1960
1961    fn definition_or_load(&self, tracker: &str) -> Result<TrackerDefinition> {
1962        if let Some(definition) = self
1963            .trackers
1964            .iter()
1965            .find(|definition| definition.name == tracker)
1966        {
1967            return Ok(definition.clone());
1968        }
1969        let path = self.store.paths().trackers.join(format!("{tracker}.toml"));
1970        if path.is_file() {
1971            return TrackerDefinition::from_file(tracker, &path);
1972        }
1973        Err(NewgitError::UnknownTracker(tracker.to_owned()))
1974    }
1975
1976    fn lane(&self, tracker: &str) -> TrackerLane {
1977        TrackerLane::new(&self.store.paths().snapshots, tracker)
1978    }
1979
1980    fn require_workspace(&self, branch: &BranchInstance) -> Result<()> {
1981        if branch.workspace_path.is_dir() {
1982            Ok(())
1983        } else {
1984            Err(NewgitError::Unsupported(format!(
1985                "the workspace for `{}` is missing at {}; spawn it again or remove the instance",
1986                branch.name, branch.workspace_path
1987            )))
1988        }
1989    }
1990}
1991
1992/// What one resource's checkpoint mode produced.
1993struct CapturedResource {
1994    mode: String,
1995    state_ref: Option<String>,
1996    state_path: Option<Utf8PathBuf>,
1997    /// Lane deposit made via `into_tracker`: (tracker, rev).
1998    deposit: Option<(String, String)>,
1999}
2000
2001impl CapturedResource {
2002    fn none() -> Self {
2003        Self {
2004            mode: "none".to_owned(),
2005            state_ref: None,
2006            state_path: None,
2007            deposit: None,
2008        }
2009    }
2010}
2011
2012fn validate_disjoint_with_replacement(
2013    definitions: &[TrackerDefinition],
2014    replacement: &TrackerDefinition,
2015) -> Result<()> {
2016    let mut updated = Vec::with_capacity(definitions.len());
2017    let mut replaced = false;
2018    for definition in definitions {
2019        if definition.name == replacement.name {
2020            updated.push(replacement.clone());
2021            replaced = true;
2022        } else {
2023            updated.push(definition.clone());
2024        }
2025    }
2026    if !replaced {
2027        updated.push(replacement.clone());
2028    }
2029    crate::tracker::validate_disjoint(&updated)
2030}