1use std::collections::{BTreeMap, BTreeSet};
2
3use camino::{Utf8Path, Utf8PathBuf};
4use chrono::Utc;
5
6use crate::branch::{
7 BranchInstance, ResourceBinding, ResourceStatus, TrackerBinding, branch_slug, validate_name,
8};
9use crate::checkpoint::{
10 CheckpointLog, CheckpointReason, CheckpointRecord, RecoveryRecord, ResourceState,
11 RestoreFailure, SourceState, TrackerState,
12};
13use crate::cleanup::{
14 ArchivedCheckpoints, CleanupOutcome, FinalizedInstance, HookDetail, HookOutcome, PrunedRev,
15 PurgedCheckpoints, SnapshotRoots, lane_revs, may_tear_down, orphan_workspaces,
16};
17use crate::config::ProjectConfig;
18use crate::error::{NewgitError, Result};
19use crate::export::{self, ExportFilter, ExportPlan, prepare_destination};
20use crate::exports::{RenderContext, render, unresolved_placeholder};
21use crate::lane::{TrackerLane, clear_owned_paths, copy_file};
22use crate::materializer::{Materializer, RealDirMaterializer, exclude_tracker_paths};
23use crate::ports;
24use crate::render::{self, RenderRecord};
25use crate::resource::{
26 Captures, CheckpointMode, GraphProblem, ResourceDefinition, RestoreMode, parse_captures,
27 resolve_order,
28};
29use crate::source::GitSource;
30use crate::store::MetadataStore;
31use crate::supervisor::{StopOutcome, Supervisor, run_captured, run_foreground};
32use crate::templates::resource_template;
33use crate::tracker::{Storage, TrackerDefinition, collect_files, collect_owned_files, content_rev};
34
35#[derive(Debug)]
37pub struct BranchManager {
38 store: MetadataStore,
39 config: ProjectConfig,
40 source: GitSource,
41 trackers: Vec<TrackerDefinition>,
42 resources: Vec<ResourceDefinition>,
43 resource_order: Vec<String>,
46 graph_problems: Vec<GraphProblem>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SpawnOutcome {
53 pub branch: BranchInstance,
54 pub record_path: Utf8PathBuf,
55 pub created_source_branch: bool,
57 pub trackers: Vec<TrackerBindOutcome>,
58 pub resources: Vec<ResourceBindOutcome>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ResourceBindOutcome {
64 pub name: String,
65 pub ports: BTreeMap<String, u16>,
66 pub status: ResourceStatus,
67 pub prepare: Option<(bool, Utf8PathBuf)>,
69 pub blocked_by: Vec<String>,
71 pub captured: Vec<String>,
73 pub missing_captures: Vec<String>,
75 pub rendered: Vec<RenderOutcome>,
77 pub render_error: Option<String>,
81}
82
83#[derive(Debug, Default)]
87struct RenderedRestore {
88 files: Vec<(Utf8PathBuf, String)>,
89}
90
91impl RenderedRestore {
92 fn restore(self) -> Result<()> {
93 for (path, contents) in self.files {
94 std::fs::write(&path, contents).map_err(|source| NewgitError::io(path, source))?;
95 }
96 Ok(())
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct RenderOutcome {
108 pub path: Utf8PathBuf,
109 pub replacements: usize,
110 pub tracker: Option<String>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum ActionOutcome {
117 Started {
119 pid: u32,
120 log: Utf8PathBuf,
121 },
122 Stopped(StopOutcome),
123 Ran {
125 code: i32,
126 log: Utf8PathBuf,
127 missing_captures: Vec<String>,
129 },
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct TrackerBindOutcome {
135 pub name: String,
136 pub content_rev: Option<String>,
137 pub files: usize,
138 pub origin: BindOrigin,
139 pub warnings: Vec<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum BindOrigin {
146 LaneHead,
148 Nothing,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct InstanceReport {
154 pub branch: BranchInstance,
155 pub workspace_exists: bool,
156 pub live_rev: Option<String>,
158 pub trackers: Vec<TrackerReport>,
159 pub resources: Vec<ResourceReport>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ResourceReport {
164 pub name: String,
165 pub state: String,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct TrackerReport {
171 pub name: String,
172 pub content_rev: Option<String>,
174 pub lane_head: Option<String>,
176}
177
178impl TrackerReport {
179 pub fn never_pulled(&self) -> bool {
181 self.lane_head.is_some() && self.content_rev.is_none()
182 }
183
184 pub fn diverged(&self) -> bool {
188 self.content_rev.is_some() && self.lane_head.is_some() && self.content_rev != self.lane_head
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct RemoveOutcome {
194 pub branch: BranchInstance,
195 pub archived_record: Utf8PathBuf,
196 pub hooks: Vec<HookOutcome>,
198 pub purged_checkpoints: Option<PurgedCheckpoints>,
200 pub kept_checkpoints: usize,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct ExportOutcome {
207 pub destination: Utf8PathBuf,
208 pub branch: String,
210 pub instance: String,
211 pub source_head: String,
213 pub commit: String,
215 pub plan: ExportPlan,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct CheckpointOutcome {
220 pub record: CheckpointRecord,
221 pub record_path: Utf8PathBuf,
222 pub warnings: Vec<String>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct UndoOutcome {
227 pub restored: CheckpointRecord,
229 pub safety: CheckpointRecord,
231 pub trackers: Vec<UndoTrackerOutcome>,
232 pub resources: Vec<UndoResourceOutcome>,
233 pub recovery_record: Option<Utf8PathBuf>,
235 pub warnings: Vec<String>,
236}
237
238impl UndoOutcome {
239 pub fn failed_resources(&self) -> Vec<&str> {
246 self.resources
247 .iter()
248 .filter(|resource| !resource.ok)
249 .map(|resource| resource.name.as_str())
250 .collect()
251 }
252
253 pub fn is_complete(&self) -> bool {
254 self.recovery_record.is_none() && self.failed_resources().is_empty()
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct UndoTrackerOutcome {
260 pub name: String,
261 pub rev: Option<String>,
263 pub files: usize,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct UndoResourceOutcome {
268 pub action: String,
271 pub name: String,
272 pub ok: bool,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct CaptureReport {
277 pub rev: String,
278 pub files: usize,
279 pub changed: bool,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct SeedReport {
285 pub rev: String,
286 pub files: usize,
287 pub changed: bool,
289 pub missing_paths: Vec<Utf8PathBuf>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct RestoreReport {
297 pub rev: String,
298 pub files: usize,
299 pub safety_rev: Option<String>,
301 pub warnings: Vec<String>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct MergeTrackerOutcome {
309 pub tracker: String,
310 pub rev: String,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct AddTrackerOutcome {
315 pub path: Utf8PathBuf,
316 pub ignored_patterns: Vec<String>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct TrackPathsOutcome {
321 pub path: Utf8PathBuf,
322 pub added_paths: Vec<Utf8PathBuf>,
323 pub ignored_patterns: Vec<String>,
324 pub seedable: bool,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct AddResourceOutcome {
331 pub path: Utf8PathBuf,
332 pub companions_created: Vec<Utf8PathBuf>,
334 pub trackers_created: Vec<Utf8PathBuf>,
336}
337
338impl BranchManager {
339 pub fn open(store: MetadataStore) -> Result<Self> {
340 store.ensure_initialized()?;
341 let config = store.load_config()?;
342 let source = GitSource::open(&store.paths().project_root, config.project.source)?;
343 let trackers = store.load_tracker_definitions()?;
344 let resources = store.load_resource_definitions()?;
345 let tracker_names: BTreeSet<String> = trackers.iter().map(|t| t.name.clone()).collect();
346 let (resource_order, graph_problems) = resolve_order(&resources, &tracker_names);
347 Ok(Self {
348 store,
349 config,
350 source,
351 trackers,
352 resources,
353 resource_order,
354 graph_problems,
355 })
356 }
357
358 pub fn graph_problems(&self) -> &[GraphProblem] {
360 &self.graph_problems
361 }
362
363 pub fn require_resolvable_graph(&self) -> Result<()> {
368 match self.graph_problems.first() {
369 Some(problem) => Err(problem.clone().into_error()),
370 None => Ok(()),
371 }
372 }
373
374 pub fn store(&self) -> &MetadataStore {
375 &self.store
376 }
377
378 fn scripts_dir(&self) -> &str {
387 self.store.paths().scripts.as_str()
388 }
389
390 pub fn tracker_definitions(&self) -> &[TrackerDefinition] {
391 &self.trackers
392 }
393
394 pub fn gitignore_warnings(&self) -> Vec<String> {
397 let mut warnings = Vec::new();
398 for definition in &self.trackers {
399 for path in &definition.paths {
400 if let Ok(true) = self.source.is_tracked(path.as_str()) {
401 warnings.push(format!(
402 "tracker `{}` owns `{path}`, which Git also tracks (dual-tracked): \
403 branch-local content will show as modifications and can be committed \
404 into source history — untrack it with `git rm --cached {path}` unless \
405 this is deliberate",
406 definition.name
407 ));
408 } else if let Ok(false) = self.source.is_ignored(path.as_str()) {
409 warnings.push(format!(
410 "tracker `{}` owns `{path}` but the store repo does not gitignore it; \
411 agents may commit it into source history (fine only if deliberately \
412 dual-tracked)",
413 definition.name
414 ));
415 }
416 }
417 }
418 warnings
419 }
420
421 pub fn spawn(&self, name: &str, from: Option<&str>) -> Result<SpawnOutcome> {
422 self.require_resolvable_graph()?;
423 validate_name(name)?;
424
425 let slug = branch_slug(name);
426 let record_path = self.store.branch_record_path(&slug);
427 if record_path.exists() {
428 return Err(NewgitError::BranchInstanceExists {
429 name: name.to_owned(),
430 path: record_path,
431 });
432 }
433
434 let created_source_branch = if self.source.branch_exists(name)? {
435 if let Some(base) = from {
436 return Err(NewgitError::Unsupported(format!(
437 "source branch `{name}` already exists; `--from {base}` only applies when \
438 creating a new branch"
439 )));
440 }
441 false
442 } else {
443 let base = from.unwrap_or("HEAD");
444 let base_rev = self.source.rev_parse(base)?;
445 self.source.create_branch(name, &base_rev)?;
446 true
447 };
448
449 let source_rev = self.source.rev_parse(&format!("refs/heads/{name}"))?;
450 let workspace_path = self
451 .config
452 .workspace_root(&self.store.paths().project_root)
453 .join(&slug);
454 let mut branch = BranchInstance::new(name, name, source_rev, workspace_path)?;
455
456 RealDirMaterializer.materialize(&self.source, &branch)?;
457
458 let owned: Vec<Utf8PathBuf> = self
462 .trackers
463 .iter()
464 .flat_map(|definition| definition.paths.iter().cloned())
465 .collect();
466 exclude_tracker_paths(&branch.workspace_path, &owned)?;
467
468 let mut tracker_outcomes = Vec::new();
469 for definition in &self.trackers {
470 let outcome = self.bind_tracker(&mut branch, definition, false)?;
471 tracker_outcomes.push(outcome);
472 }
473
474 let resource_outcomes = self.bind_resources(&mut branch)?;
475
476 let record_path = self.store.create_branch_record(&branch)?;
477
478 Ok(SpawnOutcome {
479 branch,
480 record_path,
481 created_source_branch,
482 trackers: tracker_outcomes,
483 resources: resource_outcomes,
484 })
485 }
486
487 fn bind_resources(&self, branch: &mut BranchInstance) -> Result<Vec<ResourceBindOutcome>> {
491 let mut used = ports::used_ports(&self.store.load_branches()?);
492 let mut outcomes = Vec::new();
493
494 for name in &self.resource_order {
495 let definition = self.resource_definition(name)?;
496
497 let mut resolved_ports = BTreeMap::new();
498 for (port_name, request) in &definition.ports {
499 resolved_ports.insert(
500 port_name.clone(),
501 ports::allocate(request.start, &mut used)?,
502 );
503 }
504
505 let context = RenderContext {
506 branch_name: &branch.name,
507 branch_slug: &branch.slug,
508 workspace: branch.workspace_path.as_str(),
509 scripts: self.scripts_dir(),
510 ports: Some(&resolved_ports),
511 ..RenderContext::default()
512 };
513 let resolved_exports = definition
514 .exports
515 .iter()
516 .map(|(key, value)| (key.clone(), render(value, &context)))
517 .collect();
518
519 branch.resources.insert(
520 definition.name.clone(),
521 ResourceBinding {
522 definition_rev: definition.definition_rev.clone(),
523 resolved_ports: resolved_ports.clone(),
524 resolved_exports,
525 rendered: Vec::new(),
526 status: ResourceStatus::Pending,
527 },
528 );
529
530 let (rendered, render_error) = match self.render_resource(branch, definition) {
534 Ok(rendered) => (rendered, None),
535 Err(error) => (Vec::new(), Some(error.to_string())),
536 };
537
538 let mut blocked_by = self.blocked_dependencies(branch, definition);
539 if render_error.is_some() {
540 if let Some(binding) = branch.resources.get_mut(&definition.name) {
541 binding.status = ResourceStatus::Failed;
542 }
543 outcomes.push(ResourceBindOutcome {
544 name: definition.name.clone(),
545 ports: resolved_ports,
546 status: ResourceStatus::Failed,
547 prepare: None,
548 blocked_by: std::mem::take(&mut blocked_by),
549 captured: Vec::new(),
550 missing_captures: Vec::new(),
551 rendered: Vec::new(),
552 render_error,
553 });
554 continue;
555 }
556
557 let mut captured_names = Vec::new();
561 let mut missing_captures = Vec::new();
562 let (status, prepare) = if !blocked_by.is_empty() {
563 if let Some(binding) = branch.resources.get_mut(&definition.name) {
564 binding.status = ResourceStatus::Blocked;
565 }
566 (ResourceStatus::Blocked, None)
567 } else {
568 match definition.actions.get("prepare") {
569 Some(action) if action.command.is_some() && !action.long_running => {
570 let log = self
571 .store
572 .action_log_path(&branch.slug, &format!("{}.prepare", definition.name));
573 let (code, captured) =
574 self.run_one_shot(branch, definition, action, &log)?;
575 captured_names = captured.found.keys().cloned().collect();
576 missing_captures = Self::missing_capture_warnings(
577 &definition.name,
578 "prepare",
579 &captured,
580 &log,
581 );
582 Self::apply_captures(branch, &definition.name, captured.found);
583 let status = if code == 0 {
584 ResourceStatus::Ready
585 } else {
586 ResourceStatus::Failed
587 };
588 if let Some(binding) = branch.resources.get_mut(&definition.name) {
589 binding.status = status;
590 }
591 (status, Some((code == 0, log)))
592 }
593 _ => {
594 if let Some(binding) = branch.resources.get_mut(&definition.name) {
595 binding.status = ResourceStatus::Ready;
596 }
597 (ResourceStatus::Ready, None)
598 }
599 }
600 };
601
602 outcomes.push(ResourceBindOutcome {
603 name: definition.name.clone(),
604 ports: resolved_ports,
605 status,
606 prepare,
607 blocked_by,
608 captured: captured_names,
609 missing_captures,
610 rendered,
611 render_error: None,
612 });
613 }
614 branch.updated_at = Utc::now();
615 Ok(outcomes)
616 }
617
618 fn render_resource(
624 &self,
625 branch: &mut BranchInstance,
626 definition: &ResourceDefinition,
627 ) -> Result<Vec<RenderOutcome>> {
628 if definition.render.is_empty() {
629 return Ok(Vec::new());
630 }
631
632 let context_ports = branch
633 .resources
634 .get(&definition.name)
635 .map(|binding| binding.resolved_ports.clone())
636 .unwrap_or_default();
637 let context_exports = self.bound_exports(branch);
642
643 let mut records = Vec::new();
644 let mut outcomes = Vec::new();
645 let mut source_owned = Vec::new();
646
647 for spec in &definition.render {
648 let owner = self.tracker_owning(&spec.path);
649 let committed = self.committed_content(branch, &spec.path, owner.as_deref())?;
650 let Some(committed) = committed else {
651 return Err(NewgitError::RenderPathNotCommitted {
652 resource: definition.name.clone(),
653 path: spec.path.clone(),
654 });
655 };
656
657 let context = RenderContext {
658 branch_name: &branch.name,
659 branch_slug: &branch.slug,
660 workspace: branch.workspace_path.as_str(),
661 scripts: self.scripts_dir(),
662 ports: Some(&context_ports),
663 exports: Some(&context_exports),
664 ..RenderContext::default()
665 };
666 let (contents, applied) = render::apply(&definition.name, spec, &committed, &context)?;
667
668 let target = branch.workspace_path.join(&spec.path);
669 if let Some(parent) = target.parent() {
670 crate::materializer::create_dir_all(parent)?;
671 }
672 std::fs::write(&target, contents)
673 .map_err(|source| NewgitError::io(target.clone(), source))?;
674
675 if owner.is_none() {
676 source_owned.push(spec.path.clone());
677 }
678 outcomes.push(RenderOutcome {
679 path: spec.path.clone(),
680 replacements: applied.len(),
681 tracker: owner.clone(),
682 });
683 records.push(RenderRecord {
684 path: spec.path.clone(),
685 tracker: owner,
686 applied,
687 });
688 }
689
690 GitSource::workspace_skip_worktree(&branch.workspace_path, &source_owned)?;
694
695 if let Some(binding) = branch.resources.get_mut(&definition.name) {
696 binding.rendered = records;
697 }
698 Ok(outcomes)
699 }
700
701 fn unrender_for_capture(
709 &self,
710 branch: &BranchInstance,
711 tracker: &TrackerDefinition,
712 ) -> Result<RenderedRestore> {
713 let mut restore = RenderedRestore::default();
714 for binding in branch.resources.values() {
715 for record in &binding.rendered {
716 if record.tracker.as_deref() != Some(tracker.name.as_str()) {
717 continue;
718 }
719 let path = branch.workspace_path.join(&record.path);
720 let Ok(current) = std::fs::read_to_string(&path) else {
721 continue;
722 };
723 let reversed = render::reverse(¤t, &record.applied);
724 if reversed == current {
725 continue;
726 }
727 std::fs::write(&path, &reversed)
728 .map_err(|source| NewgitError::io(path.clone(), source))?;
729 restore.files.push((path, current));
730 }
731 }
732 Ok(restore)
733 }
734
735 fn bound_exports(&self, branch: &BranchInstance) -> BTreeMap<String, String> {
738 let mut exports = BTreeMap::new();
739 for name in &self.resource_order {
740 if let Some(binding) = branch.resources.get(name) {
741 for (key, value) in &binding.resolved_exports {
742 exports.insert(key.clone(), value.clone());
743 }
744 }
745 }
746 exports
747 }
748
749 fn tracker_owning(&self, path: &Utf8Path) -> Option<String> {
752 self.trackers
753 .iter()
754 .find(|definition| {
755 definition
756 .paths
757 .iter()
758 .any(|owned| path == owned || path.starts_with(owned))
759 })
760 .map(|definition| definition.name.clone())
761 }
762
763 fn committed_content(
766 &self,
767 branch: &BranchInstance,
768 path: &Utf8Path,
769 tracker: Option<&str>,
770 ) -> Result<Option<String>> {
771 let Some(tracker) = tracker else {
772 return GitSource::workspace_show_head(&branch.workspace_path, path);
773 };
774 let Some(rev) = branch
775 .trackers
776 .get(tracker)
777 .and_then(|binding| binding.content_rev.clone())
778 else {
779 return Ok(None);
780 };
781 let source = self.lane(tracker).rev_path(&rev).join(path);
782 match std::fs::read_to_string(&source) {
783 Ok(contents) => Ok(Some(contents)),
784 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
785 Err(error) => Err(NewgitError::io(source, error)),
786 }
787 }
788
789 fn rendered_source_paths(&self, branch: &BranchInstance) -> Vec<Utf8PathBuf> {
792 branch
793 .resources
794 .values()
795 .flat_map(|binding| binding.rendered.iter())
796 .filter(|record| record.tracker.is_none())
797 .map(|record| record.path.clone())
798 .collect()
799 }
800
801 fn render_drift(&self, branch: &BranchInstance) -> Vec<String> {
822 let mut drifted = Vec::new();
823 for (resource, binding) in &branch.resources {
824 for record in &binding.rendered {
825 let Ok(Some(committed)) =
826 self.committed_content(branch, &record.path, record.tracker.as_deref())
827 else {
828 continue;
829 };
830 let Ok(expected) =
831 render::substitute(resource, &record.path, &committed, &record.applied)
832 else {
833 continue;
834 };
835 let Ok(actual) = std::fs::read_to_string(branch.workspace_path.join(&record.path))
836 else {
837 continue;
838 };
839 if actual != expected {
840 drifted.push(format!(
841 "`{}` has changes that render will discard: it is rendered by resource \
842 `{resource}`, so newgit rewrites it from committed content and this \
843 instance's values ({} line(s) differ). Move the edit into the \
844 committed file in the store repo to keep it.",
845 record.path,
846 differing_lines(&expected, &actual)
847 ));
848 }
849 }
850 }
851 drifted
852 }
853
854 fn rerender_all(&self, branch: &mut BranchInstance) -> Vec<String> {
855 let mut warnings = self.render_drift(branch);
858 for name in self.resource_order.clone() {
859 let Ok(definition) = self.resource_definition(&name) else {
860 continue;
861 };
862 if definition.render.is_empty() {
863 continue;
864 }
865 let definition = definition.clone();
866 if let Err(error) = self.render_resource(branch, &definition) {
867 warnings.push(format!("re-render for resource `{name}` failed: {error}"));
868 }
869 }
870 warnings
871 }
872
873 pub fn run_action(&self, instance: &str, spec: &str) -> Result<ActionOutcome> {
875 self.require_resolvable_graph()?;
876 let (resource_name, action_name) = spec.split_once('.').ok_or_else(|| {
877 NewgitError::Unsupported(format!("`{spec}` is not of the form <resource>.<action>"))
878 })?;
879 let mut branch = self.store.find_branch(instance)?;
880 self.require_workspace(&branch)?;
881 let definition = self.resource_definition(resource_name)?;
882 let action =
883 definition
884 .actions
885 .get(action_name)
886 .ok_or_else(|| NewgitError::UnknownAction {
887 resource: resource_name.to_owned(),
888 action: action_name.to_owned(),
889 })?;
890 let supervisor = self.supervisor(&branch);
891
892 if action.command.is_none() {
894 let signal = action
895 .signal
896 .clone()
897 .unwrap_or_else(|| definition.stop_signal());
898 return Ok(ActionOutcome::Stopped(
899 supervisor.stop(&definition.name, &signal)?,
900 ));
901 }
902
903 let blocked_by = self.blocked_dependencies(&branch, definition);
904 if !blocked_by.is_empty() {
905 if let Some(binding) = branch.resources.get_mut(&definition.name) {
906 binding.status = ResourceStatus::Blocked;
907 branch.updated_at = Utc::now();
908 self.store.save_branch_record(&branch)?;
909 }
910 return Err(NewgitError::Unsupported(format!(
911 "resource `{resource_name}` is blocked by failed dependency/dependencies: {}",
912 blocked_by.join(", ")
913 )));
914 }
915
916 let log = self
917 .store
918 .action_log_path(&branch.slug, &format!("{}.{action_name}", definition.name));
919
920 if action.long_running {
921 let command = self.rendered_command(&branch, definition, action)?;
922 let env = self.assemble_env(&branch)?;
923 let pid = supervisor.start(
924 &definition.name,
925 &command,
926 &branch.workspace_path,
927 &env,
928 &log,
929 )?;
930 return Ok(ActionOutcome::Started { pid, log });
931 }
932
933 let (code, captured) = self.run_one_shot(&branch, definition, action, &log)?;
934 let missing_captures =
935 Self::missing_capture_warnings(&definition.name, action_name, &captured, &log);
936 let mut dirty = Self::apply_captures(&mut branch, &definition.name, captured.found);
937 if action_name == "prepare"
938 && let Some(binding) = branch.resources.get_mut(&definition.name)
939 {
940 binding.status = if code == 0 {
941 ResourceStatus::Ready
942 } else {
943 ResourceStatus::Failed
944 };
945 dirty = true;
946 }
947 if dirty {
948 branch.updated_at = Utc::now();
949 self.store.save_branch_record(&branch)?;
950 }
951 Ok(ActionOutcome::Ran {
952 code,
953 log,
954 missing_captures,
955 })
956 }
957
958 pub fn run_command(
961 &self,
962 instance: &str,
963 command_line: &[String],
964 ) -> Result<(i32, Utf8PathBuf)> {
965 let branch = self.store.find_branch(instance)?;
966 self.require_workspace(&branch)?;
967 let env = self.assemble_env(&branch)?;
968 let log = self.store.action_log_path(&branch.slug, "run");
969 let code = run_foreground(command_line, &branch.workspace_path, &env, &log)?;
970 Ok((code, log))
971 }
972
973 fn run_one_shot(
978 &self,
979 branch: &BranchInstance,
980 definition: &ResourceDefinition,
981 action: &crate::resource::ActionSpec,
982 log: &Utf8Path,
983 ) -> Result<(i32, Captures)> {
984 let command = self.rendered_command(branch, definition, action)?;
985 let env = self.assemble_env(branch)?;
986
987 if action.captures.is_empty() {
988 let code = run_foreground(
989 &["sh".to_owned(), "-c".to_owned(), command],
990 &branch.workspace_path,
991 &env,
992 log,
993 )?;
994 return Ok((code, Captures::default()));
995 }
996
997 let (code, stdout) = run_captured(&command, &branch.workspace_path, &env, log)?;
998 Ok((code, parse_captures(&stdout, &action.captures)))
999 }
1000
1001 fn missing_capture_warnings(
1006 resource: &str,
1007 action: &str,
1008 captures: &Captures,
1009 log: &Utf8Path,
1010 ) -> Vec<String> {
1011 captures
1012 .missing
1013 .iter()
1014 .map(|name| {
1015 format!(
1016 "resource `{resource}` declared capture `{name}` on `{action}`, not found \
1017 in stdout (log: {log}); when `captures` is set, stdout belongs to newgit — \
1018 send everything else to stderr"
1019 )
1020 })
1021 .collect()
1022 }
1023
1024 fn apply_captures(
1029 branch: &mut BranchInstance,
1030 resource: &str,
1031 captured: BTreeMap<String, String>,
1032 ) -> bool {
1033 if captured.is_empty() {
1034 return false;
1035 }
1036 let Some(binding) = branch.resources.get_mut(resource) else {
1037 return false;
1038 };
1039 binding.resolved_exports.extend(captured);
1040 true
1041 }
1042
1043 fn rendered_command(
1044 &self,
1045 branch: &BranchInstance,
1046 definition: &ResourceDefinition,
1047 action: &crate::resource::ActionSpec,
1048 ) -> Result<String> {
1049 let command = action.command.clone().ok_or_else(|| {
1050 NewgitError::Unsupported(format!(
1051 "resource `{}` action has no command",
1052 definition.name
1053 ))
1054 })?;
1055 let context = RenderContext {
1056 branch_name: &branch.name,
1057 branch_slug: &branch.slug,
1058 workspace: branch.workspace_path.as_str(),
1059 scripts: self.scripts_dir(),
1060 ports: branch
1061 .resources
1062 .get(&definition.name)
1063 .map(|binding| &binding.resolved_ports),
1064 ..RenderContext::default()
1065 };
1066 Ok(render(&command, &context))
1067 }
1068
1069 pub fn assemble_env(&self, branch: &BranchInstance) -> Result<Vec<(String, String)>> {
1074 self.require_resolvable_graph()?;
1076 let mut env: BTreeMap<String, String> = BTreeMap::new();
1077
1078 for name in &self.resource_order {
1080 if let Some(binding) = branch.resources.get(name) {
1081 for (key, value) in &binding.resolved_exports {
1082 env.insert(key.clone(), value.clone());
1083 }
1084 }
1085 }
1086
1087 for name in &self.resource_order {
1089 let Some(binding) = branch.resources.get(name) else {
1090 continue;
1091 };
1092 let Ok(definition) = self.resource_definition(name) else {
1093 continue;
1094 };
1095 for (port_name, request) in &definition.ports {
1096 if let (Some(env_name), Some(port)) =
1097 (&request.env, binding.resolved_ports.get(port_name))
1098 {
1099 env.insert(env_name.clone(), port.to_string());
1100 }
1101 }
1102 }
1103
1104 env.insert("NEWGIT_BRANCH".to_owned(), branch.name.clone());
1106 env.insert(
1107 "NEWGIT_WORKSPACE".to_owned(),
1108 branch.workspace_path.to_string(),
1109 );
1110
1111 Ok(env.into_iter().collect())
1112 }
1113
1114 pub fn add_resource(&self, name: &str, template_name: &str) -> Result<AddResourceOutcome> {
1115 validate_name(name)?;
1116 let template = resource_template(template_name)
1117 .ok_or_else(|| NewgitError::UnknownTemplate(template_name.to_owned()))?;
1118 let path = self.store.write_resource_file(name, template.contents)?;
1119
1120 let mut companions_created = Vec::new();
1123 for companion in template.companions {
1124 let companion_path = self
1125 .store
1126 .paths()
1127 .resources
1128 .join(format!("{}.toml", companion.name));
1129 if !companion_path.exists() {
1130 companions_created.push(
1131 self.store
1132 .write_resource_file(companion.name, companion.contents)?,
1133 );
1134 }
1135 }
1136
1137 let mut trackers_created = Vec::new();
1141 for companion in template.companion_trackers {
1142 let tracker_path = self
1143 .store
1144 .paths()
1145 .trackers
1146 .join(format!("{}.toml", companion.name));
1147 if !tracker_path.exists() {
1148 trackers_created.push(
1149 self.create_tracker(
1150 companion.name,
1151 companion.audience,
1152 Storage::Local,
1153 companion.merge_with_source,
1154 )?
1155 .path,
1156 );
1157 }
1158 }
1159
1160 Ok(AddResourceOutcome {
1161 path,
1162 companions_created,
1163 trackers_created,
1164 })
1165 }
1166
1167 pub fn resource_definitions(&self) -> &[ResourceDefinition] {
1168 &self.resources
1169 }
1170
1171 fn resource_definition(&self, name: &str) -> Result<&ResourceDefinition> {
1172 self.resources
1173 .iter()
1174 .find(|definition| definition.name == name)
1175 .ok_or_else(|| NewgitError::UnknownResource(name.to_owned()))
1176 }
1177
1178 fn supervisor(&self, branch: &BranchInstance) -> Supervisor {
1179 Supervisor::new(self.store.instance_state_dir(&branch.slug))
1180 }
1181
1182 fn blocked_dependencies(
1183 &self,
1184 branch: &BranchInstance,
1185 definition: &ResourceDefinition,
1186 ) -> Vec<String> {
1187 definition
1188 .depends_on
1189 .iter()
1190 .filter_map(|dependency| {
1191 branch.resources.get(dependency).and_then(|binding| {
1192 (binding.status != ResourceStatus::Ready).then(|| dependency.clone())
1193 })
1194 })
1195 .collect()
1196 }
1197
1198 fn bind_tracker(
1202 &self,
1203 branch: &mut BranchInstance,
1204 definition: &TrackerDefinition,
1205 refresh: bool,
1206 ) -> Result<TrackerBindOutcome> {
1207 let lane = self.lane(&definition.name);
1208 let workspace = branch.workspace_path.clone();
1209
1210 if refresh && !definition.paths.is_empty() {
1212 let existing = collect_owned_files(&workspace, definition)?;
1213 if !existing.is_empty() {
1214 lane.capture(&workspace, definition)?;
1215 }
1216 }
1217
1218 let (origin, content_rev, files) = match lane.latest() {
1219 Some(rev) => {
1220 let files = lane.restore(&workspace, definition, &rev)?;
1221 (BindOrigin::LaneHead, Some(rev), files)
1222 }
1223 None => (BindOrigin::Nothing, None, 0),
1224 };
1225
1226 branch.trackers.insert(
1227 definition.name.clone(),
1228 TrackerBinding {
1229 definition_rev: definition.definition_rev.clone(),
1230 content_rev: content_rev.clone(),
1231 },
1232 );
1233 branch.updated_at = Utc::now();
1234
1235 Ok(TrackerBindOutcome {
1236 warnings: Vec::new(),
1237 name: definition.name.clone(),
1238 content_rev,
1239 files,
1240 origin,
1241 })
1242 }
1243
1244 pub fn capture_tracker(&self, instance: &str, tracker: &str) -> Result<CaptureReport> {
1245 let mut branch = self.store.find_branch(instance)?;
1246 let definition = self.definition(tracker)?;
1247 self.require_workspace(&branch)?;
1248
1249 let reversed = self.unrender_for_capture(&branch, definition)?;
1254
1255 let lane = self.lane(&definition.name);
1256 let capture = lane.capture(&branch.workspace_path, definition);
1257 reversed.restore()?;
1258 let capture = capture?;
1259
1260 let previous = branch
1261 .trackers
1262 .get(&definition.name)
1263 .and_then(|binding| binding.content_rev.clone());
1264 let changed = previous.as_deref() != Some(capture.rev.as_str());
1265 branch.trackers.insert(
1266 definition.name.clone(),
1267 TrackerBinding {
1268 definition_rev: definition.definition_rev.clone(),
1269 content_rev: Some(capture.rev.clone()),
1270 },
1271 );
1272 branch.updated_at = Utc::now();
1273 self.store.save_branch_record(&branch)?;
1274
1275 Ok(CaptureReport {
1276 rev: capture.rev,
1277 files: capture.files,
1278 changed,
1279 })
1280 }
1281
1282 pub fn seed_tracker_from_store(&self, tracker: &str) -> Result<SeedReport> {
1293 let definition = &self.definition_or_load(tracker)?;
1296 if definition.paths.is_empty() {
1297 return Err(NewgitError::TrackerHasNoPaths(definition.name.clone()));
1298 }
1299
1300 let root = self.store.paths().project_root.clone();
1301 let (present, missing): (Vec<_>, Vec<_>) = definition
1302 .paths
1303 .iter()
1304 .cloned()
1305 .partition(|path| root.join(path).exists());
1306 if present.is_empty() {
1307 return Err(NewgitError::NothingToSeed {
1308 tracker: definition.name.clone(),
1309 paths: missing
1310 .iter()
1311 .map(|path| path.as_str())
1312 .collect::<Vec<_>>()
1313 .join(", "),
1314 });
1315 }
1316
1317 let lane = self.lane(&definition.name);
1318 let capture = lane.capture(&root, definition)?;
1319 let changed = lane.latest().as_deref() != Some(capture.rev.as_str());
1320 lane.set_latest(&capture.rev)?;
1321
1322 Ok(SeedReport {
1323 rev: capture.rev,
1324 files: capture.files,
1325 changed,
1326 missing_paths: missing,
1327 })
1328 }
1329
1330 pub fn checkout_tracker(
1331 &self,
1332 instance: &str,
1333 tracker: &str,
1334 rev: Option<&str>,
1335 ) -> Result<RestoreReport> {
1336 let mut branch = self.store.find_branch(instance)?;
1337 let definition = self.definition(tracker)?;
1338 self.require_workspace(&branch)?;
1339 let lane = self.lane(&definition.name);
1340
1341 let target_rev = match rev {
1342 Some(rev) => rev.to_owned(),
1343 None => branch
1344 .trackers
1345 .get(&definition.name)
1346 .and_then(|binding| binding.content_rev.clone())
1347 .ok_or_else(|| {
1348 NewgitError::Unsupported(format!(
1349 "tracker `{}` has no bound content for `{}`; pass --rev",
1350 definition.name, branch.name
1351 ))
1352 })?,
1353 };
1354 if !lane.has_rev(&target_rev) {
1355 return Err(NewgitError::NoSnapshot {
1356 tracker: definition.name.clone(),
1357 rev: target_rev,
1358 });
1359 }
1360
1361 let reversed = self.unrender_for_capture(&branch, definition)?;
1365 let safety = lane.capture(&branch.workspace_path, definition);
1366 reversed.restore()?;
1367 let safety = safety?;
1368 let safety_rev = (safety.rev != target_rev).then_some(safety.rev);
1369
1370 let files = lane.restore(&branch.workspace_path, definition, &target_rev)?;
1371
1372 branch.trackers.insert(
1373 definition.name.clone(),
1374 TrackerBinding {
1375 definition_rev: definition.definition_rev.clone(),
1376 content_rev: Some(target_rev.clone()),
1377 },
1378 );
1379 let warnings = self.rerender_all(&mut branch);
1382 branch.updated_at = Utc::now();
1383 self.store.save_branch_record(&branch)?;
1384
1385 Ok(RestoreReport {
1386 rev: target_rev,
1387 files,
1388 safety_rev,
1389 warnings,
1390 })
1391 }
1392
1393 pub fn pull_tracker(&self, instance: &str, tracker: &str) -> Result<TrackerBindOutcome> {
1395 let mut branch = self.store.find_branch(instance)?;
1396 let definition = self.definition(tracker)?;
1397 self.require_workspace(&branch)?;
1398
1399 if self.lane(&definition.name).latest().is_none() {
1400 return Err(NewgitError::Unsupported(format!(
1401 "tracker `{}` has no merged content to pull",
1402 definition.name
1403 )));
1404 }
1405
1406 let mut outcome = self.bind_tracker(&mut branch, definition, true)?;
1407 outcome.warnings = self.rerender_all(&mut branch);
1408 self.store.save_branch_record(&branch)?;
1409 Ok(outcome)
1410 }
1411
1412 pub fn merge_tracker(&self, instance: &str, tracker: &str) -> Result<MergeTrackerOutcome> {
1414 let branch = self.store.find_branch(instance)?;
1415 let definition = self.definition(tracker)?;
1416 let rev = branch
1417 .trackers
1418 .get(&definition.name)
1419 .and_then(|binding| binding.content_rev.clone())
1420 .ok_or_else(|| {
1421 NewgitError::Unsupported(format!(
1422 "tracker `{}` has no captured content for `{}`; run `newgit tracker capture {}` first",
1423 definition.name, branch.name, definition.name
1424 ))
1425 })?;
1426 let lane = self.lane(&definition.name);
1427 if !lane.has_rev(&rev) {
1428 return Err(NewgitError::NoSnapshot {
1429 tracker: definition.name.clone(),
1430 rev,
1431 });
1432 }
1433 lane.set_latest(&rev)?;
1434 Ok(MergeTrackerOutcome {
1435 tracker: definition.name.clone(),
1436 rev,
1437 })
1438 }
1439
1440 pub fn create_tracker(
1441 &self,
1442 name: &str,
1443 audience: &str,
1444 storage: Storage,
1445 merge_with_source: bool,
1446 ) -> Result<AddTrackerOutcome> {
1447 validate_name(name)?;
1448 let definition = TrackerDefinition::new(
1449 name,
1450 audience.to_owned(),
1451 storage,
1452 merge_with_source,
1453 Vec::new(),
1454 )?;
1455 let path = self.store.create_tracker_definition(&definition)?;
1456 Ok(AddTrackerOutcome {
1457 path,
1458 ignored_patterns: Vec::new(),
1459 })
1460 }
1461
1462 pub fn track_paths(&self, tracker: &str, paths: &[Utf8PathBuf]) -> Result<TrackPathsOutcome> {
1463 let definition = self.definition_or_load(tracker)?;
1464 let updated = definition.with_added_paths(paths)?;
1465 validate_disjoint_with_replacement(&self.trackers, &updated)?;
1466 let path = self.store.save_tracker_definition(&updated)?;
1467
1468 let mut patterns = Vec::new();
1469 for owned in paths {
1470 if !self.source.is_ignored(owned.as_str())? {
1471 patterns.push(format!("/{owned}"));
1472 }
1473 }
1474 self.store.append_gitignore(tracker, &patterns)?;
1475
1476 let root = &self.store.paths().project_root;
1479 let seedable = paths.iter().any(|owned| root.join(owned).exists());
1480
1481 Ok(TrackPathsOutcome {
1482 path,
1483 added_paths: paths.to_vec(),
1484 ignored_patterns: patterns,
1485 seedable,
1486 })
1487 }
1488
1489 pub fn statuses(&self) -> Result<Vec<InstanceReport>> {
1490 let lane_heads: Vec<(String, Option<String>)> = self
1491 .trackers
1492 .iter()
1493 .map(|definition| {
1494 (
1495 definition.name.clone(),
1496 self.lane(&definition.name).latest(),
1497 )
1498 })
1499 .collect();
1500
1501 self.store
1502 .load_branches()?
1503 .into_iter()
1504 .map(|branch| {
1505 let workspace_exists = branch.workspace_path.is_dir();
1506 let live_rev = workspace_exists
1507 .then(|| GitSource::workspace_short_head(&branch.workspace_path).ok())
1508 .flatten();
1509 let trackers = self
1510 .trackers
1511 .iter()
1512 .map(|definition| {
1513 let content_rev = branch
1514 .trackers
1515 .get(&definition.name)
1516 .and_then(|binding| binding.content_rev.clone());
1517 let head = lane_heads
1518 .iter()
1519 .find(|(name, _)| name == &definition.name)
1520 .and_then(|(_, head)| head.clone());
1521 TrackerReport {
1522 name: definition.name.clone(),
1523 content_rev,
1524 lane_head: head,
1525 }
1526 })
1527 .collect();
1528 let supervisor = self.supervisor(&branch);
1529 let resources = self
1530 .resources
1531 .iter()
1532 .map(|definition| {
1533 let state = match branch.resources.get(&definition.name) {
1534 None => "—".to_owned(),
1535 Some(binding) => {
1536 if supervisor.running_pid(&definition.name).is_some() {
1537 "running".to_owned()
1538 } else if definition.has_long_running_action()
1539 && binding.status == ResourceStatus::Ready
1540 {
1541 "stopped".to_owned()
1542 } else {
1543 match binding.status {
1544 ResourceStatus::Pending => "pending".to_owned(),
1545 ResourceStatus::Ready => "ready".to_owned(),
1546 ResourceStatus::Failed => "failed".to_owned(),
1547 ResourceStatus::Blocked => "blocked".to_owned(),
1548 }
1549 }
1550 }
1551 };
1552 ResourceReport {
1553 name: definition.name.clone(),
1554 state,
1555 }
1556 })
1557 .collect();
1558 Ok(InstanceReport {
1559 branch,
1560 workspace_exists,
1561 live_rev,
1562 trackers,
1563 resources,
1564 })
1565 })
1566 .collect()
1567 }
1568
1569 pub fn remove(
1586 &self,
1587 name: &str,
1588 cwd: &Utf8Path,
1589 checkpoints: ArchivedCheckpoints,
1590 ) -> Result<RemoveOutcome> {
1591 let branch = self.store.find_branch(name)?;
1592
1593 if cwd.starts_with(&branch.workspace_path) {
1594 return Err(NewgitError::Unsupported(format!(
1595 "the current directory is inside the workspace of `{}`; step out of it before \
1596 removing",
1597 branch.name
1598 )));
1599 }
1600
1601 let supervisor = self.supervisor(&branch);
1603 for definition in &self.resources {
1604 if supervisor.running_pid(&definition.name).is_some() {
1605 supervisor.stop(&definition.name, &definition.stop_signal())?;
1606 }
1607 }
1608
1609 let hooks = self.run_cleanup_hooks(&branch, false)?;
1610
1611 let state_dir = self.store.instance_state_dir(&branch.slug);
1612 if state_dir.exists() {
1613 std::fs::remove_dir_all(&state_dir)
1614 .map_err(|source| NewgitError::io(state_dir, source))?;
1615 }
1616
1617 RealDirMaterializer.remove(&branch)?;
1618 let archived_record = self.store.archive_branch_record(&branch)?;
1619 let keeping = checkpoints == ArchivedCheckpoints::Keep;
1622 let found = self.purge_checkpoints(&branch.slug, keeping)?;
1623 let (purged_checkpoints, kept_checkpoints) = match (keeping, found) {
1624 (true, found) => (None, found.map_or(0, |plan| plan.checkpoints)),
1625 (false, purged) => (purged, 0),
1626 };
1627
1628 Ok(RemoveOutcome {
1629 branch,
1630 archived_record,
1631 hooks,
1632 purged_checkpoints,
1633 kept_checkpoints,
1634 })
1635 }
1636
1637 fn purge_checkpoints(&self, slug: &str, dry_run: bool) -> Result<Option<PurgedCheckpoints>> {
1647 let dir = self.store.checkpoint_dir(slug);
1648 if !dir.is_dir() {
1649 return Ok(None);
1650 }
1651 let checkpoints = CheckpointLog::new(dir.clone(), slug).list()?.len();
1652 let refs = self
1653 .source
1654 .refs_under(&format!("refs/newgit/checkpoints/{slug}"))?;
1655
1656 if !dry_run {
1657 for name in &refs {
1658 self.source.delete_ref(name)?;
1659 }
1660 std::fs::remove_dir_all(&dir).map_err(|source| NewgitError::io(&dir, source))?;
1661 }
1662
1663 Ok(Some(PurgedCheckpoints {
1664 slug: slug.to_owned(),
1665 checkpoints,
1666 source_refs: refs.len(),
1667 dir,
1668 }))
1669 }
1670
1671 fn run_cleanup_hooks(
1676 &self,
1677 branch: &BranchInstance,
1678 dry_run: bool,
1679 ) -> Result<Vec<HookOutcome>> {
1680 let cwd = if branch.workspace_path.is_dir() {
1684 branch.workspace_path.clone()
1685 } else {
1686 self.store.paths().project_root.clone()
1687 };
1688
1689 let mut outcomes = Vec::new();
1690 for name in self.resource_order.iter().rev() {
1691 if !branch.resources.contains_key(name) {
1692 continue;
1693 }
1694 let definition = self.resource_definition(name)?;
1695 let ownership = definition.ownership;
1696
1697 if !may_tear_down(ownership) {
1698 outcomes.push(HookOutcome {
1699 resource: name.clone(),
1700 ownership,
1701 detail: HookDetail::SkippedOwnership,
1702 });
1703 continue;
1704 }
1705
1706 let Some(template) = definition
1707 .cleanup
1708 .as_ref()
1709 .and_then(|spec| spec.command.as_deref())
1710 else {
1711 outcomes.push(HookOutcome {
1712 resource: name.clone(),
1713 ownership,
1714 detail: HookDetail::NoHook,
1715 });
1716 continue;
1717 };
1718
1719 let binding = branch.resources.get(name);
1720 let state_ref = self.checkpointed_state_ref(branch, name)?;
1721 let context = RenderContext {
1722 branch_name: &branch.name,
1723 branch_slug: &branch.slug,
1724 workspace: branch.workspace_path.as_str(),
1725 scripts: self.scripts_dir(),
1726 ports: binding.map(|binding| &binding.resolved_ports),
1727 exports: binding.map(|binding| &binding.resolved_exports),
1728 snapshot_path: None,
1729 state_ref: state_ref.as_deref(),
1730 };
1731 let command = render(template, &context);
1732
1733 if let Some(placeholder) = unresolved_placeholder(&command) {
1734 outcomes.push(HookOutcome {
1735 resource: name.clone(),
1736 ownership,
1737 detail: HookDetail::SkippedUnresolved {
1738 command: command.clone(),
1739 placeholder: placeholder.to_owned(),
1740 },
1741 });
1742 continue;
1743 }
1744
1745 if dry_run {
1746 outcomes.push(HookOutcome {
1747 resource: name.clone(),
1748 ownership,
1749 detail: HookDetail::WouldRun(command),
1750 });
1751 continue;
1752 }
1753
1754 let log = self
1755 .store
1756 .action_log_path(&branch.slug, &format!("{name}.cleanup"));
1757 let env = self.assemble_env(branch)?;
1758 let (code, _) = run_captured(&command, &cwd, &env, &log)?;
1759 outcomes.push(HookOutcome {
1760 resource: name.clone(),
1761 ownership,
1762 detail: HookDetail::Ran {
1763 command,
1764 ok: code == 0,
1765 log,
1766 },
1767 });
1768 }
1769 Ok(outcomes)
1770 }
1771
1772 fn checkpointed_state_ref(
1776 &self,
1777 branch: &BranchInstance,
1778 resource: &str,
1779 ) -> Result<Option<String>> {
1780 let records = self.checkpoint_log(branch).list()?;
1781 for record in records.iter().rev() {
1782 if let Some(state) = record
1783 .resource_states
1784 .iter()
1785 .find(|state| state.name == resource)
1786 {
1787 let resolved = state
1788 .state_path
1789 .as_ref()
1790 .map(ToString::to_string)
1791 .or_else(|| state.state_ref.clone());
1792 if resolved.is_some() {
1793 return Ok(resolved);
1794 }
1795 }
1796 }
1797 Ok(None)
1798 }
1799
1800 pub fn cleanup(&self, dry_run: bool, archived: ArchivedCheckpoints) -> Result<CleanupOutcome> {
1810 let mut outcome = CleanupOutcome {
1811 dry_run,
1812 ..CleanupOutcome::default()
1813 };
1814 let branches = self.store.load_branches()?;
1815
1816 let (live, stale): (Vec<_>, Vec<_>) = branches
1820 .iter()
1821 .partition(|branch| branch.workspace_path.is_dir());
1822
1823 for branch in &stale {
1824 let hooks = self.run_cleanup_hooks(branch, dry_run)?;
1825 let archived_record = if dry_run {
1826 None
1827 } else {
1828 let state_dir = self.store.instance_state_dir(&branch.slug);
1829 if state_dir.exists() {
1830 std::fs::remove_dir_all(&state_dir)
1831 .map_err(|source| NewgitError::io(state_dir, source))?;
1832 }
1833 Some(self.store.archive_branch_record(branch)?)
1834 };
1835 outcome.finalized.push(FinalizedInstance {
1836 name: branch.name.clone(),
1837 workspace: branch.workspace_path.clone(),
1838 hooks,
1839 archived_record,
1840 });
1841 }
1842
1843 let workspace_root = self.config.workspace_root(&self.store.paths().project_root);
1846 let (orphans, unrecognized) = orphan_workspaces(&workspace_root, &branches)?;
1847 outcome.warnings.extend(unrecognized);
1848 for orphan in orphans {
1849 if !dry_run {
1850 std::fs::remove_dir_all(&orphan)
1851 .map_err(|source| NewgitError::io(&orphan, source))?;
1852 }
1853 outcome.orphan_workspaces.push(orphan);
1854 }
1855
1856 for branch in &live {
1859 outcome
1860 .dead_state
1861 .extend(self.supervisor(branch).prune_dead_pids(dry_run)?);
1862 }
1863 let live_state_dirs: BTreeSet<Utf8PathBuf> = live
1864 .iter()
1865 .map(|branch| self.store.instance_state_dir(&branch.slug))
1866 .collect();
1867 for state_dir in self.store.state_dirs()? {
1868 if live_state_dirs.contains(&state_dir) {
1869 continue;
1870 }
1871 if !dry_run {
1872 std::fs::remove_dir_all(&state_dir)
1873 .map_err(|source| NewgitError::io(&state_dir, source))?;
1874 }
1875 outcome.dead_state.push(state_dir);
1876 }
1877
1878 let surviving: Vec<BranchInstance> = live.into_iter().cloned().collect();
1879
1880 if archived == ArchivedCheckpoints::Purge {
1884 let live_slugs: BTreeSet<&str> = surviving
1885 .iter()
1886 .map(|branch| branch.slug.as_str())
1887 .collect();
1888 for slug in self.store.checkpointed_slugs()? {
1889 if live_slugs.contains(slug.as_str()) {
1890 continue;
1891 }
1892 if let Some(purged) = self.purge_checkpoints(&slug, dry_run)? {
1893 outcome.purged_checkpoints.push(purged);
1894 }
1895 }
1896 }
1897
1898 let roots = SnapshotRoots::collect(&self.store, &surviving, archived)?;
1901 for lane_rev in lane_revs(&self.store.paths().snapshots)? {
1902 if !lane_rev.is_staging && roots.contains(&lane_rev.tracker, &lane_rev.rev) {
1903 continue;
1904 }
1905 if !dry_run {
1906 std::fs::remove_dir_all(&lane_rev.path)
1907 .map_err(|source| NewgitError::io(&lane_rev.path, source))?;
1908 }
1909 outcome.pruned.push(PrunedRev {
1910 tracker: lane_rev.tracker,
1911 rev: lane_rev.rev,
1912 path: lane_rev.path,
1913 });
1914 }
1915 outcome.pinned_by_checkpoints = roots.pinned_only_by_checkpoints().count();
1916 outcome.pinned_by_archived = roots.pinned_only_by_archived_checkpoints().count();
1917
1918 Ok(outcome)
1919 }
1920
1921 pub fn export(
1928 &self,
1929 instance: &str,
1930 destination: &Utf8Path,
1931 filter: &ExportFilter,
1932 ) -> Result<ExportOutcome> {
1933 let branch = self.store.find_branch(instance)?;
1934 self.require_workspace(&branch)?;
1935 prepare_destination(destination)?;
1936
1937 let workspace = branch.workspace_path.clone();
1938 let source_files = GitSource::workspace_tracked_files(&workspace)?;
1939 let plan = export::plan(&workspace, &source_files, &self.trackers, filter)?;
1940
1941 if plan.files.is_empty() {
1942 return Err(NewgitError::Unsupported(format!(
1943 "nothing to export from `{}`: every candidate path was withheld by audience or \
1944 excluded",
1945 branch.name
1946 )));
1947 }
1948
1949 let rendered = self.rendered_source_paths(&branch);
1954 for file in &plan.files {
1955 let target = destination.join(&file.path);
1956 if rendered.contains(&file.path) {
1957 let committed =
1958 GitSource::workspace_show_head(&workspace, &file.path)?.unwrap_or_default();
1959 if let Some(parent) = target.parent() {
1960 crate::materializer::create_dir_all(parent)?;
1961 }
1962 std::fs::write(&target, committed)
1963 .map_err(|source| NewgitError::io(target, source))?;
1964 continue;
1965 }
1966 copy_file(&workspace.join(&file.path), &target)?;
1967 }
1968
1969 let head_rev = GitSource::workspace_head(&workspace)?;
1970 let commit = GitSource::init_export_repo(
1971 destination,
1972 &branch.source_ref,
1973 &format!(
1974 "Export of `{}` at {}",
1975 branch.name,
1976 &head_rev[..8.min(head_rev.len())]
1977 ),
1978 )?;
1979
1980 Ok(ExportOutcome {
1981 destination: destination.to_path_buf(),
1982 branch: branch.source_ref.clone(),
1983 instance: branch.name.clone(),
1984 source_head: head_rev,
1985 commit,
1986 plan,
1987 })
1988 }
1989
1990 pub fn checkpoint(&self, instance: &str, message: Option<&str>) -> Result<CheckpointOutcome> {
1992 self.require_resolvable_graph()?;
1993 let mut branch = self.store.find_branch(instance)?;
1994 self.checkpoint_branch(&mut branch, message, CheckpointReason::Explicit)
1995 }
1996
1997 pub fn list_checkpoints(&self, instance: &str) -> Result<Vec<CheckpointRecord>> {
1998 let branch = self.store.find_branch(instance)?;
1999 self.checkpoint_log(&branch).list()
2000 }
2001
2002 fn checkpoint_branch(
2003 &self,
2004 branch: &mut BranchInstance,
2005 message: Option<&str>,
2006 reason: CheckpointReason,
2007 ) -> Result<CheckpointOutcome> {
2008 self.require_workspace(branch)?;
2009 let mut warnings = self.render_drift(branch);
2014 let checkpoint_log = self.checkpoint_log(branch);
2015 let id = checkpoint_log.next_id()?;
2016 let workspace = branch.workspace_path.clone();
2017
2018 let head_rev = GitSource::workspace_head(&workspace)?;
2024 let dirty_rev = GitSource::workspace_dirty_commit(
2025 &workspace,
2026 &format!("newgit {id}: uncommitted state of `{}`", branch.name),
2027 &self.rendered_source_paths(branch),
2028 )?;
2029 let tip = dirty_rev.clone().unwrap_or_else(|| head_rev.clone());
2030 let workspace_ref = format!("refs/newgit/checkpoints/{id}");
2031 let store_ref = format!("refs/newgit/checkpoints/{}/{id}", branch.slug);
2032 GitSource::workspace_update_ref(&workspace, &workspace_ref, &tip)?;
2033 let fetched = self
2034 .source
2035 .fetch_ref(&workspace, &workspace_ref, &store_ref);
2036 GitSource::workspace_delete_ref(&workspace, &workspace_ref)?;
2037 fetched?;
2038 self.bless_store_branch(branch, &head_rev, None, &mut warnings)?;
2039
2040 let mut resource_states = Vec::new();
2043 let mut deposits: Vec<(String, String)> = Vec::new();
2044 for name in self.resource_order.iter().rev() {
2045 let Some(binding) = branch.resources.get(name) else {
2046 continue;
2047 };
2048 let definition = self.resource_definition(name)?;
2049 let was_running = self.supervisor(branch).running_pid(name).is_some();
2050 let captured = self.checkpoint_resource(branch, definition)?;
2051 if let Some(deposit) = captured.deposit {
2052 deposits.push(deposit);
2053 }
2054 resource_states.push(ResourceState {
2055 name: name.clone(),
2056 definition_rev: definition.definition_rev.clone(),
2057 mode: captured.mode,
2058 state_ref: captured.state_ref,
2059 state_path: captured.state_path,
2060 was_running,
2061 resolved_ports: binding.resolved_ports.clone(),
2062 resolved_exports: binding.resolved_exports.clone(),
2063 });
2064 }
2065 resource_states.reverse();
2067 for (tracker, rev) in deposits {
2068 let definition = self.definition(&tracker)?;
2069 branch.trackers.insert(
2070 tracker,
2071 TrackerBinding {
2072 definition_rev: definition.definition_rev.clone(),
2073 content_rev: Some(rev),
2074 },
2075 );
2076 }
2077
2078 let mut tracker_states = Vec::new();
2082 for definition in &self.trackers {
2083 let content_rev = if definition.paths.is_empty() {
2084 branch
2085 .trackers
2086 .get(&definition.name)
2087 .and_then(|binding| binding.content_rev.clone())
2088 } else {
2089 Some(
2090 self.lane(&definition.name)
2091 .capture(&workspace, definition)?
2092 .rev,
2093 )
2094 };
2095 branch.trackers.insert(
2096 definition.name.clone(),
2097 TrackerBinding {
2098 definition_rev: definition.definition_rev.clone(),
2099 content_rev: content_rev.clone(),
2100 },
2101 );
2102 tracker_states.push(TrackerState {
2103 name: definition.name.clone(),
2104 definition_rev: definition.definition_rev.clone(),
2105 content_rev,
2106 });
2107 }
2108
2109 let record = CheckpointRecord {
2110 id,
2111 branch: branch.name.clone(),
2112 created_at: Utc::now(),
2113 message: message.map(ToOwned::to_owned),
2114 reason,
2115 undo_completed: None,
2116 source: SourceState {
2117 head_rev,
2118 dirty_rev,
2119 store_ref,
2120 },
2121 tracker_states,
2122 resource_states,
2123 };
2124 let record_path = checkpoint_log.save(&record)?;
2125 branch.updated_at = Utc::now();
2126 self.store.save_branch_record(branch)?;
2127
2128 Ok(CheckpointOutcome {
2129 record,
2130 record_path,
2131 warnings,
2132 })
2133 }
2134
2135 fn checkpoint_resource(
2136 &self,
2137 branch: &BranchInstance,
2138 definition: &ResourceDefinition,
2139 ) -> Result<CapturedResource> {
2140 let Some(spec) = &definition.checkpoint else {
2141 return Ok(CapturedResource::none());
2142 };
2143 let binding = branch.resources.get(&definition.name);
2144
2145 match spec.mode {
2146 CheckpointMode::None => Ok(CapturedResource::none()),
2147 CheckpointMode::Hash => {
2148 let files = collect_files(&branch.workspace_path, &spec.paths)?;
2149 let rev = content_rev(&files)?;
2150 Ok(CapturedResource {
2151 mode: "hash".to_owned(),
2152 state_ref: Some(format!("hash:{rev}")),
2153 state_path: None,
2154 deposit: None,
2155 })
2156 }
2157 CheckpointMode::Command => {
2158 let template = spec.command.as_deref().expect("validated at parse time");
2159
2160 if let Some(tracker) = &spec.into_tracker
2163 && !self.trackers.iter().any(|t| &t.name == tracker)
2164 {
2165 return Err(NewgitError::InvalidDefinition {
2166 tracker: definition.name.clone(),
2167 reason: format!(
2168 "checkpoint `into_tracker = \"{tracker}\"` names a tracker that is \
2169 not defined; create it with `newgit tracker create {tracker}`"
2170 ),
2171 });
2172 }
2173
2174 let staging = spec
2178 .into_tracker
2179 .as_ref()
2180 .map(|_| {
2181 tempfile::tempdir()
2182 .map_err(|source| NewgitError::io(&branch.workspace_path, source))
2183 })
2184 .transpose()?;
2185 let staging_path = staging
2186 .as_ref()
2187 .map(|dir| {
2188 Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
2189 .map_err(|path| NewgitError::NonUtf8Path(path.display().to_string()))
2190 })
2191 .transpose()?;
2192
2193 let context = RenderContext {
2194 branch_name: &branch.name,
2195 branch_slug: &branch.slug,
2196 workspace: branch.workspace_path.as_str(),
2197 scripts: self.scripts_dir(),
2198 ports: binding.map(|binding| &binding.resolved_ports),
2199 exports: binding.map(|binding| &binding.resolved_exports),
2200 snapshot_path: staging_path.as_deref().map(Utf8Path::as_str),
2201 state_ref: None,
2202 };
2203 let command = render(template, &context);
2204 let log = self
2205 .store
2206 .action_log_path(&branch.slug, &format!("{}.checkpoint", definition.name));
2207 let env = self.assemble_env(branch)?;
2208 let (code, stdout) = run_captured(&command, &branch.workspace_path, &env, &log)?;
2209 if code != 0 {
2210 return Err(NewgitError::CheckpointCommandFailed {
2211 resource: definition.name.clone(),
2212 code,
2213 log,
2214 });
2215 }
2216
2217 match (&spec.into_tracker, &staging_path) {
2218 (Some(tracker), Some(staging_path)) => {
2219 let lane = self.lane(tracker);
2220 let deposit = lane.deposit(staging_path)?;
2221 let state_path = Utf8Path::new(&stdout)
2224 .strip_prefix(staging_path)
2225 .map(|relative| lane.rev_path(&deposit.rev).join(relative))
2226 .unwrap_or_else(|_| lane.rev_path(&deposit.rev));
2227 Ok(CapturedResource {
2228 mode: "command".to_owned(),
2229 state_ref: Some(format!("tracker:{tracker}@{}", deposit.rev)),
2230 state_path: Some(state_path),
2231 deposit: Some((tracker.clone(), deposit.rev)),
2232 })
2233 }
2234 _ => Ok(CapturedResource {
2235 mode: "command".to_owned(),
2236 state_ref: (!stdout.is_empty()).then_some(stdout),
2237 state_path: None,
2238 deposit: None,
2239 }),
2240 }
2241 }
2242 CheckpointMode::External => {
2243 let template = spec.state_ref.as_deref().expect("validated at parse time");
2244 let context = RenderContext {
2245 branch_name: &branch.name,
2246 branch_slug: &branch.slug,
2247 workspace: branch.workspace_path.as_str(),
2248 scripts: self.scripts_dir(),
2249 ports: binding.map(|binding| &binding.resolved_ports),
2250 exports: binding.map(|binding| &binding.resolved_exports),
2251 ..RenderContext::default()
2252 };
2253 Ok(CapturedResource {
2254 mode: "external".to_owned(),
2255 state_ref: Some(render(template, &context)),
2256 state_path: None,
2257 deposit: None,
2258 })
2259 }
2260 }
2261 }
2262
2263 pub fn undo(&self, instance: &str, to: Option<&str>) -> Result<UndoOutcome> {
2267 self.require_resolvable_graph()?;
2268 let mut branch = self.store.find_branch(instance)?;
2269 self.require_workspace(&branch)?;
2270 let checkpoint_log = self.checkpoint_log(&branch);
2271 let restored = match to {
2272 Some(id) => checkpoint_log.load(id)?,
2273 None => checkpoint_log.latest()?,
2274 };
2275
2276 let safety = self.checkpoint_branch(
2277 &mut branch,
2278 Some(&format!("state before undo to {}", restored.id)),
2279 CheckpointReason::BeforeUndo,
2280 )?;
2281 let mut warnings = safety.warnings.clone();
2282
2283 let supervisor = self.supervisor(&branch);
2285 for definition in &self.resources {
2286 if supervisor.running_pid(&definition.name).is_some() {
2287 supervisor.stop(&definition.name, &definition.stop_signal())?;
2288 }
2289 }
2290
2291 let workspace = branch.workspace_path.clone();
2293 GitSource::workspace_fetch_ref(&workspace, self.source.root(), &restored.source.store_ref)?;
2294 GitSource::workspace_restore_to(
2295 &workspace,
2296 &restored.source.head_rev,
2297 restored.source.dirty_rev.as_deref(),
2298 )?;
2299 self.bless_store_branch(
2302 &mut branch,
2303 &restored.source.head_rev,
2304 Some(&safety.record.source.head_rev),
2305 &mut warnings,
2306 )?;
2307
2308 let mut trackers = Vec::new();
2311 for state in &restored.tracker_states {
2312 let Some(definition) = self
2313 .trackers
2314 .iter()
2315 .find(|definition| definition.name == state.name)
2316 else {
2317 warnings.push(format!(
2318 "tracker `{}` from the checkpoint is no longer defined; its content was \
2319 not restored",
2320 state.name
2321 ));
2322 continue;
2323 };
2324 if definition.definition_rev != state.definition_rev {
2325 warnings.push(format!(
2326 "tracker `{}` definition changed since the checkpoint; content was \
2327 restored against the current definition",
2328 state.name
2329 ));
2330 }
2331 let files = match &state.content_rev {
2332 Some(rev) => self
2333 .lane(&definition.name)
2334 .restore(&workspace, definition, rev)?,
2335 None => {
2336 clear_owned_paths(&workspace, definition)?;
2337 0
2338 }
2339 };
2340 branch.trackers.insert(
2341 state.name.clone(),
2342 TrackerBinding {
2343 definition_rev: definition.definition_rev.clone(),
2344 content_rev: state.content_rev.clone(),
2345 },
2346 );
2347 trackers.push(UndoTrackerOutcome {
2348 name: state.name.clone(),
2349 rev: state.content_rev.clone(),
2350 files,
2351 });
2352 }
2353
2354 warnings.extend(self.rerender_all(&mut branch));
2361
2362 let mut resources = Vec::new();
2365 let mut failures: Vec<RestoreFailure> = Vec::new();
2366 for name in &self.resource_order {
2367 let Some(state) = restored
2368 .resource_states
2369 .iter()
2370 .find(|state| &state.name == name)
2371 else {
2372 continue;
2373 };
2374 if !branch.resources.contains_key(name) {
2375 continue;
2376 }
2377 let definition = self.resource_definition(name)?;
2378 if definition.definition_rev != state.definition_rev {
2379 warnings.push(format!(
2380 "resource `{name}` definition changed since the checkpoint; restored with \
2381 the current definition"
2382 ));
2383 }
2384
2385 let (mut action_label, ok) = self.restore_resource(
2386 &mut branch,
2387 definition,
2388 state,
2389 &mut failures,
2390 &mut warnings,
2391 )?;
2392 if let Some(binding) = branch.resources.get_mut(name) {
2393 binding.status = if ok {
2394 ResourceStatus::Ready
2395 } else {
2396 ResourceStatus::Failed
2397 };
2398 }
2399
2400 if ok && state.was_running {
2401 match self.restart_long_running(&branch, definition) {
2402 Ok(true) => action_label.push_str(" + restarted"),
2403 Ok(false) => warnings.push(format!(
2404 "resource `{name}` was running at checkpoint time but has no \
2405 long-running action to restart"
2406 )),
2407 Err(error) => failures.push(RestoreFailure {
2408 resource: name.clone(),
2409 detail: format!("restart failed: {error}"),
2410 log: None,
2411 retry_with: format!("newgit action {name}.start {}", branch.name),
2412 }),
2413 }
2414 }
2415 resources.push(UndoResourceOutcome {
2416 name: name.clone(),
2417 action: action_label,
2418 ok,
2419 });
2420 }
2421
2422 let recovery_record = if failures.is_empty() {
2423 None
2424 } else {
2425 for failure in &failures {
2426 if let Some(binding) = branch.resources.get_mut(&failure.resource) {
2427 binding.status = ResourceStatus::Failed;
2428 }
2429 }
2430 Some(checkpoint_log.save_recovery(&RecoveryRecord {
2431 checkpoint: restored.id.clone(),
2432 branch: branch.name.clone(),
2433 created_at: Utc::now(),
2434 failures,
2435 })?)
2436 };
2437
2438 branch.updated_at = Utc::now();
2439 self.store.save_branch_record(&branch)?;
2440
2441 let mut safety_record = safety.record;
2447 safety_record.undo_completed = Some(recovery_record.is_none());
2448 checkpoint_log.save(&safety_record)?;
2449
2450 Ok(UndoOutcome {
2451 restored,
2452 safety: safety_record,
2453 trackers,
2454 resources,
2455 recovery_record,
2456 warnings,
2457 })
2458 }
2459
2460 fn restore_resource(
2464 &self,
2465 branch: &mut BranchInstance,
2466 definition: &ResourceDefinition,
2467 state: &ResourceState,
2468 failures: &mut Vec<RestoreFailure>,
2469 warnings: &mut Vec<String>,
2470 ) -> Result<(String, bool)> {
2471 let Some(spec) = &definition.restore else {
2472 return Ok(("none".to_owned(), true));
2473 };
2474 match spec.mode {
2475 RestoreMode::None => Ok(("none".to_owned(), true)),
2476 RestoreMode::External => Ok(("external (no-op)".to_owned(), true)),
2477 RestoreMode::Recompute => {
2478 let action_name = spec.recompute_action();
2479 let action = definition.actions.get(action_name).ok_or_else(|| {
2480 NewgitError::UnknownAction {
2481 resource: definition.name.clone(),
2482 action: action_name.to_owned(),
2483 }
2484 })?;
2485 let log = self
2486 .store
2487 .action_log_path(&branch.slug, &format!("{}.restore", definition.name));
2488 let (code, captured) = self.run_one_shot(branch, definition, action, &log)?;
2489 warnings.extend(Self::missing_capture_warnings(
2490 &definition.name,
2491 action_name,
2492 &captured,
2493 &log,
2494 ));
2495 Self::apply_captures(branch, &definition.name, captured.found);
2496 let ok = code == 0;
2497 if !ok {
2498 failures.push(RestoreFailure {
2499 resource: definition.name.clone(),
2500 detail: format!("recompute action `{action_name}` exited with {code}"),
2501 log: Some(log),
2502 retry_with: format!(
2503 "newgit action {}.{action_name} {}",
2504 definition.name, branch.name
2505 ),
2506 });
2507 }
2508 Ok((format!("recompute({action_name})"), ok))
2509 }
2510 RestoreMode::Command => {
2511 let template = spec.command.as_deref().expect("validated at parse time");
2512 let binding = branch.resources.get(&definition.name);
2513 let state_ref = state
2514 .state_path
2515 .as_ref()
2516 .map(ToString::to_string)
2517 .or_else(|| state.state_ref.clone());
2518 let context = RenderContext {
2519 branch_name: &branch.name,
2520 branch_slug: &branch.slug,
2521 workspace: branch.workspace_path.as_str(),
2522 scripts: self.scripts_dir(),
2523 ports: binding.map(|binding| &binding.resolved_ports),
2524 exports: binding.map(|binding| &binding.resolved_exports),
2525 snapshot_path: None,
2526 state_ref: state_ref.as_deref(),
2527 };
2528 let command = render(template, &context);
2529 let log = self
2530 .store
2531 .action_log_path(&branch.slug, &format!("{}.restore", definition.name));
2532 let env = self.assemble_env(branch)?;
2533 let (code, _) = run_captured(&command, &branch.workspace_path, &env, &log)?;
2534 let ok = code == 0;
2535 if !ok {
2536 failures.push(RestoreFailure {
2537 resource: definition.name.clone(),
2538 detail: format!("restore command exited with {code}"),
2539 log: Some(log),
2540 retry_with: "repair the resource, then `newgit undo` again".to_owned(),
2541 });
2542 }
2543 Ok(("command".to_owned(), ok))
2544 }
2545 }
2546 }
2547
2548 fn restart_long_running(
2551 &self,
2552 branch: &BranchInstance,
2553 definition: &ResourceDefinition,
2554 ) -> Result<bool> {
2555 let Some((action_name, action)) = definition
2556 .actions
2557 .iter()
2558 .find(|(_, action)| action.long_running)
2559 else {
2560 return Ok(false);
2561 };
2562 let log = self
2563 .store
2564 .action_log_path(&branch.slug, &format!("{}.{action_name}", definition.name));
2565 let command = self.rendered_command(branch, definition, action)?;
2566 let env = self.assemble_env(branch)?;
2567 self.supervisor(branch).start(
2568 &definition.name,
2569 &command,
2570 &branch.workspace_path,
2571 &env,
2572 &log,
2573 )?;
2574 Ok(true)
2575 }
2576
2577 fn bless_store_branch(
2583 &self,
2584 branch: &mut BranchInstance,
2585 head_rev: &str,
2586 expected_old: Option<&str>,
2587 warnings: &mut Vec<String>,
2588 ) -> Result<()> {
2589 let branch_ref = format!("refs/heads/{}", branch.source_ref);
2590 if let Some(old) = self.source.ref_rev(&branch_ref)
2591 && old != head_rev
2592 && expected_old != Some(old.as_str())
2593 && !self.source.is_ancestor(&old, head_rev)?
2594 {
2595 warnings.push(format!(
2596 "store branch `{}` had commits this workspace does not (was at {}); it now \
2597 points at {} — the old commits remain in the store repository but no branch \
2598 ref reaches them",
2599 branch.source_ref,
2600 &old[..8.min(old.len())],
2601 &head_rev[..8.min(head_rev.len())]
2602 ));
2603 }
2604 self.source.update_ref(&branch_ref, head_rev)?;
2605 branch.source_rev = head_rev.to_owned();
2606 Ok(())
2607 }
2608
2609 fn checkpoint_log(&self, branch: &BranchInstance) -> CheckpointLog {
2610 CheckpointLog::new(self.store.checkpoint_dir(&branch.slug), &branch.name)
2611 }
2612
2613 fn definition(&self, tracker: &str) -> Result<&TrackerDefinition> {
2614 self.trackers
2615 .iter()
2616 .find(|definition| definition.name == tracker)
2617 .ok_or_else(|| NewgitError::UnknownTracker(tracker.to_owned()))
2618 }
2619
2620 fn definition_or_load(&self, tracker: &str) -> Result<TrackerDefinition> {
2621 if let Some(definition) = self
2622 .trackers
2623 .iter()
2624 .find(|definition| definition.name == tracker)
2625 {
2626 return Ok(definition.clone());
2627 }
2628 let path = self.store.paths().trackers.join(format!("{tracker}.toml"));
2629 if path.is_file() {
2630 return TrackerDefinition::from_file(tracker, &path);
2631 }
2632 Err(NewgitError::UnknownTracker(tracker.to_owned()))
2633 }
2634
2635 fn lane(&self, tracker: &str) -> TrackerLane {
2636 TrackerLane::new(&self.store.paths().snapshots, tracker)
2637 }
2638
2639 fn require_workspace(&self, branch: &BranchInstance) -> Result<()> {
2640 if branch.workspace_path.is_dir() {
2641 Ok(())
2642 } else {
2643 Err(NewgitError::Unsupported(format!(
2644 "the workspace for `{}` is missing at {}; spawn it again or remove the instance",
2645 branch.name, branch.workspace_path
2646 )))
2647 }
2648 }
2649}
2650
2651struct CapturedResource {
2653 mode: String,
2654 state_ref: Option<String>,
2655 state_path: Option<Utf8PathBuf>,
2656 deposit: Option<(String, String)>,
2658}
2659
2660impl CapturedResource {
2661 fn none() -> Self {
2662 Self {
2663 mode: "none".to_owned(),
2664 state_ref: None,
2665 state_path: None,
2666 deposit: None,
2667 }
2668 }
2669}
2670
2671fn differing_lines(expected: &str, actual: &str) -> usize {
2674 let expected: Vec<&str> = expected.lines().collect();
2675 let actual: Vec<&str> = actual.lines().collect();
2676 let common = expected
2677 .iter()
2678 .zip(actual.iter())
2679 .filter(|(left, right)| left != right)
2680 .count();
2681 common + expected.len().abs_diff(actual.len())
2682}
2683
2684fn validate_disjoint_with_replacement(
2685 definitions: &[TrackerDefinition],
2686 replacement: &TrackerDefinition,
2687) -> Result<()> {
2688 let mut updated = Vec::with_capacity(definitions.len());
2689 let mut replaced = false;
2690 for definition in definitions {
2691 if definition.name == replacement.name {
2692 updated.push(replacement.clone());
2693 replaced = true;
2694 } else {
2695 updated.push(definition.clone());
2696 }
2697 }
2698 if !replaced {
2699 updated.push(replacement.clone());
2700 }
2701 crate::tracker::validate_disjoint(&updated)
2702}