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#[derive(Debug)]
35pub struct BranchManager {
36 store: MetadataStore,
37 config: ProjectConfig,
38 source: GitSource,
39 trackers: Vec<TrackerDefinition>,
40 resources: Vec<ResourceDefinition>,
41 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 pub created_source_branch: bool,
51 pub trackers: Vec<TrackerBindOutcome>,
52 pub resources: Vec<ResourceBindOutcome>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ResourceBindOutcome {
58 pub name: String,
59 pub ports: BTreeMap<String, u16>,
60 pub status: ResourceStatus,
61 pub prepare: Option<(bool, Utf8PathBuf)>,
63 pub blocked_by: Vec<String>,
65 pub captured: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ActionOutcome {
72 Started {
74 pid: u32,
75 log: Utf8PathBuf,
76 },
77 Stopped(StopOutcome),
78 Ran {
80 code: i32,
81 log: Utf8PathBuf,
82 },
83}
84
85#[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 LaneHead,
98 Nothing,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct InstanceReport {
104 pub branch: BranchInstance,
105 pub workspace_exists: bool,
106 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 pub state: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct TrackerReport {
121 pub name: String,
122 pub content_rev: Option<String>,
124 pub lane_head: Option<String>,
126}
127
128impl TrackerReport {
129 pub fn never_pulled(&self) -> bool {
131 self.lane_head.is_some() && self.content_rev.is_none()
132 }
133
134 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 pub hooks: Vec<HookOutcome>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ExportOutcome {
152 pub destination: Utf8PathBuf,
153 pub branch: String,
155 pub instance: String,
156 pub source_head: String,
158 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 pub restored: CheckpointRecord,
174 pub safety: CheckpointRecord,
176 pub trackers: Vec<UndoTrackerOutcome>,
177 pub resources: Vec<UndoResourceOutcome>,
178 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 pub rev: Option<String>,
188 pub files: usize,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct UndoResourceOutcome {
193 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 pub changed: bool,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct RestoreReport {
210 pub rev: String,
211 pub files: usize,
212 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 pub companions_created: Vec<Utf8PathBuf>,
240 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 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 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 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 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 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 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 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 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 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 pub fn assemble_env(&self, branch: &BranchInstance) -> Result<Vec<(String, String)>> {
627 let mut env: BTreeMap<String, String> = BTreeMap::new();
628
629 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 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 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 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 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 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 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 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 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 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 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 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 fn run_cleanup_hooks(
1097 &self,
1098 branch: &BranchInstance,
1099 dry_run: bool,
1100 ) -> Result<Vec<HookOutcome>> {
1101 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1992struct CapturedResource {
1994 mode: String,
1995 state_ref: Option<String>,
1996 state_path: Option<Utf8PathBuf>,
1997 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}