Skip to main content

newgit_core/
branch.rs

1use std::collections::BTreeMap;
2
3use camino::Utf8PathBuf;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{NewgitError, Result};
8use crate::render::RenderRecord;
9
10/// The binding record. The workspace directory is disposable; this is not.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct BranchInstance {
13    pub id: String,
14    pub name: String,
15    pub slug: String,
16    /// Git branch in the store repo (also the branch checked out in the clone).
17    pub source_ref: String,
18    /// Revision the workspace was materialized at.
19    pub source_rev: String,
20    pub workspace_path: Utf8PathBuf,
21    pub status: InstanceStatus,
22    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
23    pub trackers: BTreeMap<String, TrackerBinding>,
24    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
25    pub resources: BTreeMap<String, ResourceBinding>,
26    pub created_at: DateTime<Utc>,
27    pub updated_at: DateTime<Utc>,
28}
29
30/// Which concrete instance of a resource this branch instance is bound to:
31/// its allocated ports and rendered exports.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct ResourceBinding {
34    pub definition_rev: String,
35    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
36    pub resolved_ports: BTreeMap<String, u16>,
37    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
38    pub resolved_exports: BTreeMap<String, String>,
39    /// Files this resource rendered per-instance values into, with the
40    /// substitutions as actually applied. Recorded rather than re-derived so
41    /// `newgit capture` can reverse a render whose definition has since been
42    /// edited — the binding record is the source of truth for what this
43    /// instance is, including what was written into its files.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub rendered: Vec<RenderRecord>,
46    pub status: ResourceStatus,
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "kebab-case")]
51pub enum ResourceStatus {
52    /// Bound; prepare has not succeeded yet.
53    Pending,
54    Ready,
55    Failed,
56    /// Not attempted because a resource dependency is failed or blocked.
57    Blocked,
58}
59
60/// Which content revision of a tracker this instance is bound to.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct TrackerBinding {
63    pub definition_rev: String,
64    /// None when the tracker is bound but no content has been captured yet.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub content_rev: Option<String>,
67}
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "kebab-case")]
71pub enum InstanceStatus {
72    Active,
73}
74
75impl BranchInstance {
76    pub fn new(
77        name: &str,
78        source_ref: impl Into<String>,
79        source_rev: impl Into<String>,
80        workspace_path: Utf8PathBuf,
81    ) -> Result<Self> {
82        validate_name(name)?;
83        let slug = branch_slug(name);
84        let now = Utc::now();
85        Ok(Self {
86            id: format!("br_{slug}_{}", now.timestamp_millis()),
87            name: name.to_owned(),
88            slug,
89            source_ref: source_ref.into(),
90            source_rev: source_rev.into(),
91            workspace_path,
92            status: InstanceStatus::Active,
93            trackers: BTreeMap::new(),
94            resources: BTreeMap::new(),
95            created_at: now,
96            updated_at: now,
97        })
98    }
99
100    pub fn short_rev(&self) -> &str {
101        self.source_rev.get(..8).unwrap_or(&self.source_rev)
102    }
103}
104
105/// Filesystem-safe identifier derived from the instance name; used for the
106/// workspace directory and the record filename.
107pub fn branch_slug(name: &str) -> String {
108    let mut slug = String::with_capacity(name.len());
109    let mut previous_dash = false;
110
111    for ch in name.chars().flat_map(char::to_lowercase) {
112        if ch.is_ascii_alphanumeric() {
113            slug.push(ch);
114            previous_dash = false;
115        } else if !previous_dash {
116            slug.push('-');
117            previous_dash = true;
118        }
119    }
120
121    slug.trim_matches('-').to_owned()
122}
123
124/// Names double as Git branch names, so stay well inside ref-name rules.
125pub fn validate_name(name: &str) -> Result<()> {
126    let starts_ok = name
127        .chars()
128        .next()
129        .is_some_and(|ch| ch.is_ascii_alphanumeric());
130    let chars_ok = name
131        .chars()
132        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/'));
133    let refname_ok = !name.contains("..") && !name.ends_with('/') && !name.ends_with(".lock");
134
135    if starts_ok && chars_ok && refname_ok {
136        Ok(())
137    } else {
138        Err(NewgitError::InvalidName(name.to_owned()))
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::{branch_slug, validate_name};
145
146    #[test]
147    fn slug_flattens_and_lowercases() {
148        assert_eq!(branch_slug("Feature/A_b"), "feature-a-b");
149        assert_eq!(branch_slug("auth-refactor"), "auth-refactor");
150    }
151
152    #[test]
153    fn names_stay_inside_git_ref_rules() {
154        assert!(validate_name("feature/login").is_ok());
155        assert!(validate_name("-flag").is_err());
156        assert!(validate_name("a..b").is_err());
157        assert!(validate_name("a.lock").is_err());
158        assert!(validate_name("").is_err());
159    }
160}