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}