cepler/
workspace.rs

1use super::{config::*, database::*, repo::*};
2use anyhow::*;
3use std::collections::HashMap;
4use std::path::Path;
5
6pub struct Workspace {
7    path_to_config: String,
8    scope: String,
9    ignore_queue: bool,
10    db: Database,
11}
12
13pub struct StateId {
14    pub head_commit: String,
15    pub version: u32,
16}
17
18impl Workspace {
19    pub fn new(scope: &str, path_to_config: String, ignore_queue: bool) -> Result<Self> {
20        Ok(Self {
21            db: Database::open(scope, &path_to_config, ignore_queue)?,
22            scope: scope.to_string(),
23            path_to_config,
24            ignore_queue,
25        })
26    }
27
28    pub fn ls(&self, env: &EnvironmentConfig, gate: Option<String>) -> Result<Vec<String>> {
29        let repo = Repo::open(gate)?;
30        let new_env_state = self.construct_env_state(&repo, env, false)?;
31        Ok(new_env_state.files.into_keys().map(|k| k.name()).collect())
32    }
33
34    pub fn check(
35        &self,
36        env: &EnvironmentConfig,
37        gate: Option<String>,
38    ) -> Result<Option<(StateId, Vec<FileDiff>)>> {
39        let repo = Repo::open(gate)?;
40        if let Some(previous_env) = env.propagated_from() {
41            self.db.get_current_state(previous_env).context(format!(
42                "Previous environment '{}' not deployed yet",
43                previous_env
44            ))?;
45        }
46        let new_env_state = self.construct_env_state(&repo, env, false)?;
47        let (version, diffs) = if let Some((version, last)) = self.db.get_current_state(&env.name) {
48            let diffs = new_env_state.diff(last);
49            if diffs.is_empty() {
50                return Ok(None);
51            }
52            (version + 1, diffs)
53        } else {
54            (
55                1,
56                new_env_state
57                    .files
58                    .iter()
59                    .map(|(ident, state)| FileDiff {
60                        ident: ident.clone(),
61                        current_state: Some(state.clone()),
62                        added: true,
63                    })
64                    .collect(),
65            )
66        };
67        for diff in diffs.iter() {
68            let name = diff.ident.name();
69            if diff.added {
70                eprintln!("File {} was added", name)
71            } else if diff.current_state.is_some() {
72                eprintln!("File {} changed", name)
73            } else {
74                eprintln!("File {} was removed", name)
75            }
76        }
77        Ok(Some((
78            StateId {
79                version,
80                head_commit: new_env_state.head_commit.inner(),
81            },
82            diffs,
83        )))
84    }
85
86    pub fn reproduce(&self, env: &EnvironmentConfig, force_clean: bool) -> Result<StateId> {
87        let repo = Repo::open(None)?;
88        if let Some((version, last_state)) = self.db.get_current_state(&env.name) {
89            if force_clean {
90                repo.checkout_gate(&[], &self.ignore_list(), true)?;
91            }
92            for (ident, state) in last_state.files.iter() {
93                repo.checkout_file_from(&ident.name(), &state.from_commit)?;
94            }
95            Ok(StateId {
96                version,
97                head_commit: last_state.head_commit.clone().inner(),
98            })
99        } else {
100            Err(anyhow!("No state recorded for {}", env.name))
101        }
102    }
103
104    pub fn prepare(
105        &self,
106        env: &EnvironmentConfig,
107        gate: Option<String>,
108        force_clean: bool,
109    ) -> Result<()> {
110        let repo = Repo::open(gate)?;
111        let head_patterns: Vec<_> = env.head_file_patterns().collect();
112        repo.checkout_gate(&head_patterns, &self.ignore_list(), force_clean)?;
113        let new_env_state = self.construct_env_state(&repo, env, false)?;
114        for (ident, state) in new_env_state.files.iter() {
115            if ident.propagated() {
116                repo.checkout_file_from(&ident.name(), &state.from_commit)?;
117            }
118        }
119        Ok(())
120    }
121
122    pub fn record_env(
123        &mut self,
124        env: &EnvironmentConfig,
125        gate: Option<String>,
126        commit: bool,
127        reset: bool,
128        git_config: Option<GitConfig>,
129    ) -> Result<(StateId, Vec<FileDiff>)> {
130        eprintln!("Recording current state");
131        let repo = Repo::open(gate)?;
132        let new_env_state = self.construct_env_state(&repo, env, true)?;
133        let head_commit = new_env_state.head_commit.clone().inner();
134        let diffs = if let Some((_, last_state)) = self.db.get_current_state(&env.name) {
135            new_env_state.diff(last_state)
136        } else {
137            new_env_state
138                .files
139                .iter()
140                .map(|(ident, state)| FileDiff {
141                    ident: ident.clone(),
142                    current_state: Some(state.clone()),
143                    added: true,
144                })
145                .collect()
146        };
147        let (version, state_file) = self.db.set_current_environment_state(
148            env.name.clone(),
149            env.propagated_from().cloned(),
150            new_env_state,
151        )?;
152        if commit {
153            eprintln!("Adding commit to repository to persist state");
154            repo.commit_state_file(&self.scope, state_file)?;
155        }
156        if reset {
157            eprintln!("Reseting head to have a clean workspace");
158            repo.checkout_head()?;
159        }
160        if let Some(config) = git_config {
161            eprintln!("Pushing to remote");
162            if !repo.push(config)? {
163                eprintln!("... there was nothing new to push");
164            }
165        }
166        Ok((
167            StateId {
168                head_commit,
169                version,
170            },
171            diffs,
172        ))
173    }
174
175    #[allow(clippy::redundant_closure)]
176    fn construct_env_state(
177        &self,
178        repo: &Repo,
179        env: &EnvironmentConfig,
180        recording: bool,
181    ) -> Result<DeployState> {
182        let current_commit = repo.gate_commit_hash();
183        let database = self.db.open_env_from_commit(
184            &self.path_to_config,
185            self.ignore_queue,
186            &self.scope,
187            env,
188            current_commit.clone(),
189            repo,
190        )?;
191
192        let mut best_state = self.construct_state_for_commit(
193            repo,
194            current_commit.clone(),
195            env,
196            &database,
197            recording,
198        )?;
199        repo.walk_commits_before(current_commit, |commit| {
200            if let Some(state) =
201                self.get_state_if_equivalent(&env.name, repo, &best_state, commit, recording)?
202            {
203                best_state = state;
204                Ok(true)
205            } else {
206                Ok(false)
207            }
208        })?;
209        Ok(best_state)
210    }
211
212    fn get_state_if_equivalent(
213        &self,
214        env_name: &str,
215        repo: &Repo,
216        last_state: &DeployState,
217        commit: CommitHash,
218        recording: bool,
219    ) -> Result<Option<DeployState>> {
220        let config = if let Some(config) =
221            repo.get_file_content(commit.clone(), Path::new(&self.path_to_config), |bytes| {
222                Config::from_reader(bytes)
223            })? {
224            config
225        } else {
226            return Ok(None);
227        };
228        let env = if let Some(env) = config.environments.get(env_name) {
229            env
230        } else {
231            return Ok(None);
232        };
233        let database = self.db.open_env_from_commit(
234            &self.path_to_config,
235            self.ignore_queue,
236            &config.scope,
237            env,
238            commit.clone(),
239            repo,
240        )?;
241        let new_state = self.construct_state_for_commit(repo, commit, env, &database, recording)?;
242        if last_state.diff(&new_state).is_empty() {
243            Ok(Some(new_state))
244        } else {
245            Ok(None)
246        }
247    }
248
249    fn construct_state_for_commit(
250        &self,
251        repo: &Repo,
252        commit: CommitHash,
253        env: &EnvironmentConfig,
254        database: &Database,
255        recording: bool,
256    ) -> Result<DeployState> {
257        let mut new_env_state = DeployState::new(commit.clone());
258        let mut inserted_files = HashMap::new();
259        if let Some(previous_env) = env.propagated_from() {
260            let patterns: Vec<_> = env.propagated_file_patterns().collect();
261            if let Some(passed_state) = database.get_target_propagated_state(
262                &env.name,
263                env.ignore_queue,
264                previous_env,
265                &patterns,
266            ) {
267                new_env_state.propagated_head = Some(passed_state.head_commit.clone());
268                for (ident, prev_state) in passed_state.files.iter() {
269                    let name = ident.name();
270                    if let Some(last_hash) = prev_state.file_hash.as_ref() {
271                        if patterns
272                            .iter()
273                            .any(|p| p.matches_with(&name, MATCH_OPTIONS))
274                        {
275                            let (dirty, file_hash) = if recording {
276                                if let Some(file_hash) = hash_file(&name) {
277                                    (&file_hash != last_hash, Some(file_hash))
278                                } else {
279                                    (true, None)
280                                }
281                            } else {
282                                (false, Some(last_hash.clone()))
283                            };
284                            let file_state = FileState {
285                                dirty,
286                                file_hash,
287                                from_commit: prev_state.from_commit.clone(),
288                                message: prev_state.message.clone(),
289                            };
290                            let ident = FileIdent::new(name.clone(), Some(previous_env));
291                            inserted_files.insert(name.clone(), ident.clone());
292                            new_env_state.files.insert(ident, file_state);
293                        }
294                    }
295                }
296            }
297        }
298        let ignore_list = vec![
299            glob::Pattern::new(&self.path_to_config).unwrap(),
300            glob::Pattern::new(&format!("{}/*", database.state_dir)).unwrap(),
301        ];
302        repo.all_files(commit.clone(), |file_hash, path| {
303            if env
304                .head_file_patterns()
305                .any(|p| p.matches_path_with(path, MATCH_OPTIONS))
306                && !ignore_list
307                    .iter()
308                    .any(|p| p.matches_path_with(path, MATCH_OPTIONS))
309            {
310                let (from_commit, message) = repo.find_last_changed_commit(path, commit.clone())?;
311                let state = if recording {
312                    if let Some(on_disk_hash) = hash_file(path) {
313                        FileState {
314                            dirty: file_hash != on_disk_hash,
315                            file_hash: Some(on_disk_hash),
316                            from_commit,
317                            message,
318                        }
319                    } else {
320                        FileState {
321                            dirty: true,
322                            file_hash: None,
323                            from_commit,
324                            message,
325                        }
326                    }
327                } else {
328                    FileState {
329                        dirty: false,
330                        file_hash: Some(file_hash),
331                        from_commit,
332                        message,
333                    }
334                };
335                let file_name = path.to_str().unwrap().to_string();
336                let ident = FileIdent::new(file_name, None);
337                if let Some(ident) = inserted_files.remove(&ident.name()) {
338                    new_env_state.files.remove(&ident);
339                }
340                new_env_state.files.insert(ident, state);
341            }
342            Ok(())
343        })?;
344        Ok(new_env_state)
345    }
346
347    fn ignore_list(&self) -> Vec<glob::Pattern> {
348        vec![
349            glob::Pattern::new(&self.path_to_config).unwrap(),
350            glob::Pattern::new(&format!("{}/*", self.db.state_dir)).unwrap(),
351            glob::Pattern::new(".git/*").unwrap(),
352            glob::Pattern::new(".gitignore").unwrap(),
353        ]
354    }
355}