Compare commits
5
Commits
72e0c229f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97205c4bf | ||
|
|
6b6c031242 | ||
|
|
9f4a0dc446 | ||
|
|
3873987c19 | ||
|
|
36752606a6 |
@@ -35,14 +35,14 @@ cargo install --path .
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
dev_tools <project> <up|down> [--legacy]
|
dev_tools <project> <up|down> [-v|--verbose]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Argument | Description |
|
| Argument | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `project` | Project key (matches `<PROJECT>_DIR` in the config, case-insensitive) |
|
| `project` | Project key (matches `<PROJECT>_DIR` in the config, case-insensitive) |
|
||||||
| `up` / `down` | Start or stop the environment |
|
| `up` / `down` | Start or stop the environment |
|
||||||
| `-l`, `--legacy` | Enable legacy mode: also toggles `conf/global/connections.env` and runs `git update-index` |
|
| `-v`, `--verbose` | Print the actual before/after lines changed in the env files (values may include secrets, e.g. connection strings). Without it, only a line count is printed. |
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
@@ -53,15 +53,15 @@ dev_tools foo up
|
|||||||
# Stop it
|
# Stop it
|
||||||
dev_tools foo down
|
dev_tools foo down
|
||||||
|
|
||||||
# Start with legacy env handling
|
# See exactly which lines were changed
|
||||||
dev_tools foo up --legacy
|
dev_tools foo up -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
1. Starts or stops the Docker Compose service defined by `DOCKER_SERVICE` / `DOCKER_DIR`.
|
1. Starts or stops the Docker Compose service defined by `DOCKER_SERVICE` / `DOCKER_DIR`.
|
||||||
2. Toggles database connection entries in `<PROJECT_DIR>/conf/local/.env` between `staging` and CI values.
|
2. `conf/global/connections.env`: `up` toggles it to the CI credentials and marks it `git update-index --assume-unchanged` to avoid accidental commits; `down` unmarks it and restores it to its committed state by reading the committed blob with `git cat-file` (rather than computing the reverse toggle or running `git checkout`, which would fire the repo's `post-checkout` hook), so it's guaranteed byte-for-byte identical to HEAD.
|
||||||
3. With `--legacy`: additionally modifies `conf/global/connections.env` and marks it with `git update-index --assume-unchanged` to avoid accidental commits.
|
3. Toggles database connection entries in `<PROJECT_DIR>/conf/local/.env`: `up` switches the connection to `ci`, `down` switches it to `staging`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -15,11 +15,11 @@ pub struct Arguments {
|
|||||||
pub action: String,
|
pub action: String,
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
short = 'l',
|
short,
|
||||||
long = "legacy",
|
long,
|
||||||
help = "Legacy mode for old env reading."
|
help = "Show which lines were changed (values may include secrets)"
|
||||||
)]
|
)]
|
||||||
pub legacy: bool,
|
pub verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Arguments {
|
impl Arguments {
|
||||||
@@ -40,7 +40,7 @@ mod tests {
|
|||||||
Arguments {
|
Arguments {
|
||||||
project: "test".to_string(),
|
project: "test".to_string(),
|
||||||
action: action.to_string(),
|
action: action.to_string(),
|
||||||
legacy: false,
|
verbose: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+226
-124
@@ -1,8 +1,5 @@
|
|||||||
use std::{
|
use std::fmt;
|
||||||
fmt,
|
use std::fs;
|
||||||
fs::{self, File},
|
|
||||||
io::{BufRead, BufReader},
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
@@ -16,10 +13,16 @@ pub struct FileChange {
|
|||||||
pub diffs: Vec<(String, String)>,
|
pub diffs: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FileChange {
|
||||||
|
fn no_changes_message(&self) -> String {
|
||||||
|
format!("[!] {}: no changes", self.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for FileChange {
|
impl fmt::Display for FileChange {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
if self.diffs.is_empty() {
|
if self.diffs.is_empty() {
|
||||||
return writeln!(f, "[!] {}: no changes", self.path);
|
return writeln!(f, "{}", self.no_changes_message());
|
||||||
}
|
}
|
||||||
writeln!(f, "[!] {} changed:", self.path)?;
|
writeln!(f, "[!] {} changed:", self.path)?;
|
||||||
for (before, after) in &self.diffs {
|
for (before, after) in &self.diffs {
|
||||||
@@ -29,6 +32,22 @@ impl fmt::Display for FileChange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FileChange {
|
||||||
|
/// Line-count-only summary that never prints line contents, since those
|
||||||
|
/// can contain secrets (e.g. connection strings). Use `Display` (gated
|
||||||
|
/// behind `-v`/`--verbose` in `main`) to show the actual before/after lines.
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
if self.diffs.is_empty() {
|
||||||
|
return format!("{}\n", self.no_changes_message());
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"[!] {} changed: {} line(s) (use -v to show)\n",
|
||||||
|
self.path,
|
||||||
|
self.diffs.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// writes global connections
|
/// writes global connections
|
||||||
pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
||||||
let global_connections_env = format!(
|
let global_connections_env = format!(
|
||||||
@@ -36,17 +55,16 @@ pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
|||||||
config.project_dir_as_string(),
|
config.project_dir_as_string(),
|
||||||
GLOBAL_CONNECTION_PATH
|
GLOBAL_CONNECTION_PATH
|
||||||
);
|
);
|
||||||
let file: File = File::open(&global_connections_env)
|
let content = fs::read_to_string(&global_connections_env)
|
||||||
.with_context(|| format!("could not read file `{}`", global_connections_env))?;
|
.with_context(|| format!("could not read file `{}`", global_connections_env))?;
|
||||||
let reader: BufReader<File> = BufReader::new(file);
|
let trailing_newline = content.ends_with('\n');
|
||||||
|
|
||||||
let mut modified_lines: Vec<String> = Vec::new();
|
let mut modified_lines: Vec<String> = Vec::new();
|
||||||
let mut diffs: Vec<(String, String)> = Vec::new();
|
let mut diffs: Vec<(String, String)> = Vec::new();
|
||||||
for line in reader.lines() {
|
for line_content in content.lines() {
|
||||||
let line_content = line?;
|
let modified: Option<String> = if start && connections_env_comment(line_content) {
|
||||||
let modified: Option<String> = if start && connections_env_comment(&line_content) {
|
|
||||||
Some(format!("# {}", line_content))
|
Some(format!("# {}", line_content))
|
||||||
} else if !start && should_commented_out(&line_content) {
|
} else if !start && should_commented_out(line_content) {
|
||||||
Some(line_content[2..].to_string())
|
Some(line_content[2..].to_string())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -54,26 +72,101 @@ pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
|||||||
|
|
||||||
match modified {
|
match modified {
|
||||||
Some(new_line) => {
|
Some(new_line) => {
|
||||||
diffs.push((line_content, new_line.clone()));
|
diffs.push((line_content.to_string(), new_line.clone()));
|
||||||
modified_lines.push(new_line);
|
modified_lines.push(new_line);
|
||||||
}
|
}
|
||||||
None => modified_lines.push(line_content),
|
None => modified_lines.push(line_content.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::write(&global_connections_env, modified_lines.join("\n"))
|
let mut output = modified_lines.join("\n");
|
||||||
.context("Cannot write global connections env")?;
|
if trailing_newline {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
fs::write(&global_connections_env, output).context("Cannot write global connections env")?;
|
||||||
Ok(FileChange {
|
Ok(FileChange {
|
||||||
path: global_connections_env,
|
path: global_connections_env,
|
||||||
diffs,
|
diffs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOCKER_SECTION: &str = "#docker";
|
||||||
|
const DOCKER_SECTION_LINES: usize = 4;
|
||||||
|
const CIDB_SECTION: &str = "#cidb";
|
||||||
|
const CIDB_SECTION_LINES: usize = 2;
|
||||||
|
|
||||||
|
/// Toggles the `#docker` and `#cidb` sections and the `connection` line of
|
||||||
|
/// `conf/local/.env` in a single read/write pass. `#docker` follows `start`
|
||||||
|
/// directly; `#cidb` and the connection line follow its inverse (`up` means
|
||||||
|
/// the local docker db is reachable, so `#cidb`'s CI credentials switch off
|
||||||
|
/// and the connection moves to `ci`; `down` is the reverse, ending on
|
||||||
|
/// `staging`). Every line outside both sections is checked against
|
||||||
|
/// `connection_key` exactly once, so it can't be toggled twice.
|
||||||
pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
||||||
let mut docker_change = toggle_after_line("#docker", 4, start, config)?;
|
let local_connection_env = format!(
|
||||||
let cidb_change = toggle_after_line("#cidb", 2, !start, config)?;
|
"{}{}",
|
||||||
docker_change.diffs.extend(cidb_change.diffs);
|
config.project_dir_as_string(),
|
||||||
Ok(docker_change)
|
LOCAL_CONNECTION_PATH
|
||||||
|
);
|
||||||
|
let content = fs::read_to_string(&local_connection_env)
|
||||||
|
.with_context(|| format!("could not read file `{}`", local_connection_env))?;
|
||||||
|
let trailing_newline = content.ends_with('\n');
|
||||||
|
|
||||||
|
let mut modified_lines: Vec<String> = Vec::new();
|
||||||
|
let mut diffs: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut section: Option<(bool, usize)> = None;
|
||||||
|
|
||||||
|
for line_content in content.lines() {
|
||||||
|
let trimmed = line_content.trim_start();
|
||||||
|
|
||||||
|
if let Some((_, remaining)) = section {
|
||||||
|
if remaining == 0 {
|
||||||
|
section = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.starts_with(DOCKER_SECTION) {
|
||||||
|
section = Some((start, DOCKER_SECTION_LINES));
|
||||||
|
modified_lines.push(line_content.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with(CIDB_SECTION) {
|
||||||
|
section = Some((!start, CIDB_SECTION_LINES));
|
||||||
|
modified_lines.push(line_content.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified: Option<String> = if let Some((activate, remaining)) = &mut section {
|
||||||
|
let new_line = toggle(line_content, *activate);
|
||||||
|
*remaining -= 1;
|
||||||
|
(new_line != line_content).then_some(new_line)
|
||||||
|
} else {
|
||||||
|
connection_key(line_content).and_then(|key| {
|
||||||
|
let value = if start { "ci" } else { "staging" };
|
||||||
|
let new_line = format!("{}='{}'", key, value);
|
||||||
|
(new_line != line_content).then_some(new_line)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
match modified {
|
||||||
|
Some(new_line) => {
|
||||||
|
diffs.push((line_content.to_string(), new_line.clone()));
|
||||||
|
modified_lines.push(new_line);
|
||||||
|
}
|
||||||
|
None => modified_lines.push(line_content.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = modified_lines.join("\n");
|
||||||
|
if trailing_newline {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
fs::write(&local_connection_env, output).context("Cannot write local connection env")?;
|
||||||
|
|
||||||
|
Ok(FileChange {
|
||||||
|
path: local_connection_env,
|
||||||
|
diffs,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const CI_DB_MASTER_IP: &str = "ci.db.master.ip";
|
const CI_DB_MASTER_IP: &str = "ci.db.master.ip";
|
||||||
@@ -112,68 +205,6 @@ pub(crate) fn should_commented_out(line: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toggle_after_line(
|
|
||||||
target_line_prefix: &str,
|
|
||||||
num_lines: usize,
|
|
||||||
activate: bool,
|
|
||||||
config: &DevToolsConf,
|
|
||||||
) -> Result<FileChange> {
|
|
||||||
let local_connection_env = format!(
|
|
||||||
"{}{}",
|
|
||||||
config.project_dir_as_string(),
|
|
||||||
LOCAL_CONNECTION_PATH
|
|
||||||
);
|
|
||||||
let file: File = File::open(&local_connection_env)
|
|
||||||
.with_context(|| format!("could not read file `{}`", local_connection_env))?;
|
|
||||||
let reader: BufReader<File> = BufReader::new(file);
|
|
||||||
|
|
||||||
let mut modified_lines: Vec<String> = Vec::new();
|
|
||||||
let mut diffs: Vec<(String, String)> = Vec::new();
|
|
||||||
let mut relevant = false;
|
|
||||||
let mut counter: usize = 0;
|
|
||||||
|
|
||||||
for line in reader.lines() {
|
|
||||||
let line_content = line?;
|
|
||||||
|
|
||||||
if counter == num_lines {
|
|
||||||
relevant = false;
|
|
||||||
}
|
|
||||||
if line_content.trim_start().starts_with(target_line_prefix) {
|
|
||||||
relevant = true;
|
|
||||||
modified_lines.push(line_content);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let modified: Option<String> = if relevant {
|
|
||||||
counter += 1;
|
|
||||||
let new_line = toggle(&line_content, activate);
|
|
||||||
(new_line != line_content).then_some(new_line)
|
|
||||||
} else if let Some(key) = connection_key(&line_content) {
|
|
||||||
let value = if activate { "staging" } else { "ci" };
|
|
||||||
let new_line = format!("{}='{}'", key, value);
|
|
||||||
(new_line != line_content).then_some(new_line)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
match modified {
|
|
||||||
Some(new_line) => {
|
|
||||||
diffs.push((line_content, new_line.clone()));
|
|
||||||
modified_lines.push(new_line);
|
|
||||||
}
|
|
||||||
None => modified_lines.push(line_content),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::write(&local_connection_env, modified_lines.join("\n"))
|
|
||||||
.context("Cannot write local connection env")?;
|
|
||||||
|
|
||||||
Ok(FileChange {
|
|
||||||
path: local_connection_env,
|
|
||||||
diffs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the `connection` key as it appears on the given line (either the
|
/// Returns the `connection` key as it appears on the given line (either the
|
||||||
/// legacy `connection` or the new `RCC_CONNECTION`), if the line sets it.
|
/// legacy `connection` or the new `RCC_CONNECTION`), if the line sets it.
|
||||||
fn connection_key(line: &str) -> Option<&'static str> {
|
fn connection_key(line: &str) -> Option<&'static str> {
|
||||||
@@ -277,6 +308,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_local_db_preserves_trailing_newline() {
|
||||||
|
let dir = TempProjectDir::new("trailing_newline_global");
|
||||||
|
let path = dir.join(GLOBAL_CONNECTION_PATH);
|
||||||
|
fs::write(&path, "ci.db.master.user=admin\n").unwrap();
|
||||||
|
|
||||||
|
set_local_db(true, &config_for(&dir)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&path).unwrap(),
|
||||||
|
"ci.db.master.user=admin\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_local_db_preserves_missing_trailing_newline() {
|
||||||
|
let dir = TempProjectDir::new("no_trailing_newline_global");
|
||||||
|
let path = dir.join(GLOBAL_CONNECTION_PATH);
|
||||||
|
fs::write(&path, "ci.db.master.user=admin").unwrap();
|
||||||
|
|
||||||
|
set_local_db(true, &config_for(&dir)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&path).unwrap(),
|
||||||
|
"ci.db.master.user=admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_local_db_records_diffs_when_uncommenting() {
|
fn set_local_db_records_diffs_when_uncommenting() {
|
||||||
let dir = TempProjectDir::new("uncomment");
|
let dir = TempProjectDir::new("uncomment");
|
||||||
@@ -304,55 +363,36 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_after_line_records_diffs_for_toggled_section() {
|
fn set_dot_env_keeps_trailing_newline_when_modifying() {
|
||||||
let dir = TempProjectDir::new("toggle_section");
|
let dir = TempProjectDir::new("trailing_newline_local_modify");
|
||||||
fs::write(
|
let path = dir.join(LOCAL_CONNECTION_PATH);
|
||||||
dir.join(LOCAL_CONNECTION_PATH),
|
fs::write(&path, "connection='ci'\n").unwrap();
|
||||||
"#docker\nDOCKER_HOST=tcp://localhost:2375\n#DOCKER_TLS_VERIFY=1\n#DOCKER_CERT_PATH=/certs\nDOCKER_API_VERSION=1.41\nother=1",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let change = toggle_after_line("#docker", 4, true, &config_for(&dir)).unwrap();
|
set_dot_env(false, &config_for(&dir)).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(fs::read_to_string(&path).unwrap(), "connection='staging'\n");
|
||||||
change.diffs,
|
|
||||||
vec![
|
|
||||||
(
|
|
||||||
"#DOCKER_TLS_VERIFY=1".to_string(),
|
|
||||||
"DOCKER_TLS_VERIFY=1".to_string()
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"#DOCKER_CERT_PATH=/certs".to_string(),
|
|
||||||
"DOCKER_CERT_PATH=/certs".to_string()
|
|
||||||
),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_after_line_records_diffs_for_both_connection_keys() {
|
fn set_dot_env_preserves_trailing_newline_when_unmodified() {
|
||||||
let dir = TempProjectDir::new("connection_keys");
|
let dir = TempProjectDir::new("trailing_newline_local_noop");
|
||||||
fs::write(
|
let path = dir.join(LOCAL_CONNECTION_PATH);
|
||||||
dir.join(LOCAL_CONNECTION_PATH),
|
fs::write(&path, "OTHER_KEY=value\n").unwrap();
|
||||||
"#docker\nline1\nline2\nline3\nline4\nconnection='staging'\nRCC_CONNECTION='staging'",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let change = toggle_after_line("#cidb", 2, false, &config_for(&dir)).unwrap();
|
set_dot_env(true, &config_for(&dir)).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(fs::read_to_string(&path).unwrap(), "OTHER_KEY=value\n");
|
||||||
change.diffs,
|
}
|
||||||
vec![
|
|
||||||
(
|
#[test]
|
||||||
"connection='staging'".to_string(),
|
fn set_dot_env_preserves_missing_trailing_newline() {
|
||||||
"connection='ci'".to_string()
|
let dir = TempProjectDir::new("no_trailing_newline_local");
|
||||||
),
|
let path = dir.join(LOCAL_CONNECTION_PATH);
|
||||||
(
|
fs::write(&path, "OTHER_KEY=value").unwrap();
|
||||||
"RCC_CONNECTION='staging'".to_string(),
|
|
||||||
"RCC_CONNECTION='ci'".to_string()
|
set_dot_env(true, &config_for(&dir)).unwrap();
|
||||||
),
|
|
||||||
]
|
assert_eq!(fs::read_to_string(&path).unwrap(), "OTHER_KEY=value");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -386,6 +426,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_dot_env_merges_diffs_from_both_sections_on_stop() {
|
||||||
|
let dir = TempProjectDir::new("merge_sections_stop");
|
||||||
|
fs::write(
|
||||||
|
dir.join(LOCAL_CONNECTION_PATH),
|
||||||
|
"#docker\nDOCKER_HOST=tcp://localhost:2375\n#DOCKER_TLS_VERIFY=1\n#DOCKER_CERT_PATH=/certs\nDOCKER_API_VERSION=1.41\n#cidb\n#ci.db.master.ip=127.0.0.1\n#ci.db.master.port=5432\nconnection='ci'\nRCC_CONNECTION='ci'",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let change = set_dot_env(false, &config_for(&dir)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(change.diffs.len(), 6);
|
||||||
|
for expected in [
|
||||||
|
(
|
||||||
|
"DOCKER_HOST=tcp://localhost:2375",
|
||||||
|
"#DOCKER_HOST=tcp://localhost:2375",
|
||||||
|
),
|
||||||
|
("DOCKER_API_VERSION=1.41", "#DOCKER_API_VERSION=1.41"),
|
||||||
|
("#ci.db.master.ip=127.0.0.1", "ci.db.master.ip=127.0.0.1"),
|
||||||
|
("#ci.db.master.port=5432", "ci.db.master.port=5432"),
|
||||||
|
("connection='ci'", "connection='staging'"),
|
||||||
|
("RCC_CONNECTION='ci'", "RCC_CONNECTION='staging'"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
change
|
||||||
|
.diffs
|
||||||
|
.contains(&(expected.0.to_string(), expected.1.to_string())),
|
||||||
|
"missing diff {:?}",
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_change_display_no_diffs() {
|
fn file_change_display_no_diffs() {
|
||||||
let change = FileChange {
|
let change = FileChange {
|
||||||
@@ -425,6 +498,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_change_summary_no_diffs() {
|
||||||
|
let change = FileChange {
|
||||||
|
path: "some/path".to_string(),
|
||||||
|
diffs: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(change.summary(), "[!] some/path: no changes\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_change_summary_hides_values() {
|
||||||
|
let change = FileChange {
|
||||||
|
path: "some/path".to_string(),
|
||||||
|
diffs: vec![
|
||||||
|
(
|
||||||
|
"connection='ci'".to_string(),
|
||||||
|
"connection='staging'".to_string(),
|
||||||
|
),
|
||||||
|
("a=1".to_string(), "#a=1".to_string()),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let summary = change.summary();
|
||||||
|
assert_eq!(
|
||||||
|
summary,
|
||||||
|
"[!] some/path changed: 2 line(s) (use -v to show)\n"
|
||||||
|
);
|
||||||
|
assert!(!summary.contains("connection"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_activate_removes_leading_hash() {
|
fn toggle_activate_removes_leading_hash() {
|
||||||
assert_eq!(toggle("#value=1", true), "value=1");
|
assert_eq!(toggle("#value=1", true), "value=1");
|
||||||
|
|||||||
+197
-11
@@ -1,9 +1,25 @@
|
|||||||
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{ensure, Context, Result};
|
||||||
|
|
||||||
use crate::{config::parse::GLOBAL_CONNECTION_PATH, env::config::DevToolsConf};
|
use crate::{config::parse::GLOBAL_CONNECTION_PATH, env::config::DevToolsConf};
|
||||||
|
|
||||||
|
/// Runs `git <args>` in `dir` and errors if the process didn't exit
|
||||||
|
/// successfully, so a failed checkout/update-index can't be mistaken for one
|
||||||
|
/// that succeeded.
|
||||||
|
fn run_git(dir: &str, args: &[&str]) -> Result<()> {
|
||||||
|
let status = Command::new("git").current_dir(dir).args(args).status()?;
|
||||||
|
|
||||||
|
ensure!(
|
||||||
|
status.success(),
|
||||||
|
"git {} failed with {status}",
|
||||||
|
args.join(" ")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> {
|
pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> {
|
||||||
let change: &str = match start {
|
let change: &str = match start {
|
||||||
true => "--assume-unchanged",
|
true => "--assume-unchanged",
|
||||||
@@ -15,17 +31,187 @@ pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> {
|
|||||||
GLOBAL_CONNECTION_PATH
|
GLOBAL_CONNECTION_PATH
|
||||||
);
|
);
|
||||||
|
|
||||||
Command::new("git")
|
run_git(
|
||||||
.current_dir(config.project_dir_as_string())
|
&config.project_dir_as_string(),
|
||||||
.args(["update-index", change, &project_path])
|
&["update-index", change, &project_path],
|
||||||
.status()?;
|
)
|
||||||
|
|
||||||
if !start {
|
|
||||||
Command::new("git")
|
|
||||||
.current_dir(config.project_dir_as_string())
|
|
||||||
.args(["checkout", &project_path])
|
|
||||||
.status()?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores `connections.env` to its committed state, used on `down` instead
|
||||||
|
/// of computing the reverse toggle so the file ends up byte-for-byte
|
||||||
|
/// identical to HEAD, with no risk of a parsing mismatch. Reads the
|
||||||
|
/// committed blob with `git cat-file` and writes it directly rather than
|
||||||
|
/// running `git checkout`, since `checkout` fires `post-checkout` even for a
|
||||||
|
/// single-file checkout and `cat-file` (plumbing, no worktree update) never
|
||||||
|
/// triggers hooks.
|
||||||
|
pub fn restore_connections_env(config: &DevToolsConf) -> Result<()> {
|
||||||
|
let project_dir = config.project_dir_as_string();
|
||||||
|
let project_path = format!("{project_dir}{GLOBAL_CONNECTION_PATH}");
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.current_dir(&project_dir)
|
||||||
|
.args(["cat-file", "-p", &format!("HEAD:{GLOBAL_CONNECTION_PATH}")])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"git cat-file -p HEAD:{GLOBAL_CONNECTION_PATH} failed with {}",
|
||||||
|
output.status
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::write(&project_path, output.stdout).context("Cannot write connections.env")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// An isolated git repository under the OS temp dir, for exercising
|
||||||
|
/// `toggle_index`/`restore_connections_env` against a real `git`
|
||||||
|
/// process without touching this repo's own history. Namespaced by
|
||||||
|
/// process id so concurrent `cargo test` runs can't collide, and
|
||||||
|
/// removed on drop so no stale directories accumulate.
|
||||||
|
struct TempGitRepo(PathBuf);
|
||||||
|
|
||||||
|
impl TempGitRepo {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"dev_tools_git_test_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
name
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(dir.join("conf/global")).unwrap();
|
||||||
|
|
||||||
|
let run = |args: &[&str]| {
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(&dir)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
};
|
||||||
|
run(&["init", "-q"]);
|
||||||
|
run(&["config", "user.email", "test@example.com"]);
|
||||||
|
run(&["config", "user.name", "test"]);
|
||||||
|
|
||||||
|
Self(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_connections_env(&self, content: &str) {
|
||||||
|
fs::write(self.join(GLOBAL_CONNECTION_PATH), content).unwrap();
|
||||||
|
let run = |args: &[&str]| {
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(&self.0)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
};
|
||||||
|
run(&["add", GLOBAL_CONNECTION_PATH]);
|
||||||
|
run(&["commit", "-q", "-m", "connections.env"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for TempGitRepo {
|
||||||
|
type Target = Path;
|
||||||
|
fn deref(&self) -> &Path {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempGitRepo {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_for(dir: &Path) -> DevToolsConf {
|
||||||
|
DevToolsConf {
|
||||||
|
container_service: String::new(),
|
||||||
|
container_dir: String::new(),
|
||||||
|
project_directory: PathBuf::from(format!("{}/", dir.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_connections_env_reverts_uncommitted_changes() {
|
||||||
|
let repo = TempGitRepo::new("restore_ok");
|
||||||
|
repo.commit_connections_env("ci.db.user=\"m.rothenhaeusler\"\n");
|
||||||
|
fs::write(repo.join(GLOBAL_CONNECTION_PATH), "corrupted content\n").unwrap();
|
||||||
|
|
||||||
|
restore_connections_env(&config_for(&repo)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(repo.join(GLOBAL_CONNECTION_PATH)).unwrap(),
|
||||||
|
"ci.db.user=\"m.rothenhaeusler\"\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_connections_env_does_not_trigger_post_checkout_hook() {
|
||||||
|
let repo = TempGitRepo::new("no_hook");
|
||||||
|
repo.commit_connections_env("ci.db.user=\"m.rothenhaeusler\"\n");
|
||||||
|
fs::write(repo.join(GLOBAL_CONNECTION_PATH), "corrupted content\n").unwrap();
|
||||||
|
|
||||||
|
let marker = repo.join("hook-ran");
|
||||||
|
let hooks_dir = repo.join(".git/hooks");
|
||||||
|
fs::create_dir_all(&hooks_dir).unwrap();
|
||||||
|
let hook_path = hooks_dir.join("post-checkout");
|
||||||
|
fs::write(
|
||||||
|
&hook_path,
|
||||||
|
format!("#!/bin/sh\ntouch {}\n", marker.display()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
|
||||||
|
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
||||||
|
fs::set_permissions(&hook_path, perms).unwrap();
|
||||||
|
|
||||||
|
restore_connections_env(&config_for(&repo)).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!marker.exists(),
|
||||||
|
"restore_connections_env must not trigger post-checkout hooks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_connections_env_fails_when_not_a_git_repo() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"dev_tools_git_test_{}_not_a_repo",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(dir.join("conf/global")).unwrap();
|
||||||
|
fs::write(dir.join(GLOBAL_CONNECTION_PATH), "content\n").unwrap();
|
||||||
|
|
||||||
|
let result = restore_connections_env(&config_for(&dir));
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_index_marks_file_assume_unchanged() {
|
||||||
|
let repo = TempGitRepo::new("toggle_index");
|
||||||
|
repo.commit_connections_env("content\n");
|
||||||
|
|
||||||
|
toggle_index(true, &config_for(&repo)).unwrap();
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.current_dir(&*repo)
|
||||||
|
.args(["ls-files", "-v", GLOBAL_CONNECTION_PATH])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let listing = String::from_utf8(output.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
listing.starts_with('h'),
|
||||||
|
"expected lower-case assume-unchanged flag, got: {listing}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+59
-10
@@ -4,8 +4,8 @@ use env::config::{load_config, DevToolsConf};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
arguments::Arguments,
|
arguments::Arguments,
|
||||||
config::parse::{set_dot_env, set_local_db},
|
config::parse::{set_dot_env, set_local_db, FileChange},
|
||||||
git::command::toggle_index,
|
git::command::{restore_connections_env, toggle_index},
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
@@ -15,6 +15,54 @@ mod container;
|
|||||||
mod env;
|
mod env;
|
||||||
mod git;
|
mod git;
|
||||||
|
|
||||||
|
/// Line-count summary by default, since a full diff can contain secrets
|
||||||
|
/// (e.g. connection strings); `-v`/`--verbose` opts into the diff.
|
||||||
|
fn file_change_output(change: &FileChange, verbose: bool) -> String {
|
||||||
|
if verbose {
|
||||||
|
change.to_string()
|
||||||
|
} else {
|
||||||
|
change.summary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_file_change(change: &FileChange, verbose: bool) {
|
||||||
|
print!("{}", file_change_output(change, verbose));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn change_with_diff() -> FileChange {
|
||||||
|
FileChange {
|
||||||
|
path: "some/path".to_string(),
|
||||||
|
diffs: vec![(
|
||||||
|
"connection='ci'".to_string(),
|
||||||
|
"connection='staging'".to_string(),
|
||||||
|
)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_change_output_hides_values_by_default() {
|
||||||
|
let output = file_change_output(&change_with_diff(), false);
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
"[!] some/path changed: 1 line(s) (use -v to show)\n"
|
||||||
|
);
|
||||||
|
assert!(!output.contains("connection"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_change_output_shows_values_when_verbose() {
|
||||||
|
let output = file_change_output(&change_with_diff(), true);
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
"[!] some/path changed:\n connection='ci' -> connection='staging'\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args: Arguments = Arguments::parse();
|
let args: Arguments = Arguments::parse();
|
||||||
args.validate()?;
|
args.validate()?;
|
||||||
@@ -22,15 +70,16 @@ fn main() -> Result<()> {
|
|||||||
let start: bool = args.action == "up";
|
let start: bool = args.action == "up";
|
||||||
|
|
||||||
start_docker_compose(&config.container_service, &config.container_dir, start)?;
|
start_docker_compose(&config.container_service, &config.container_dir, start)?;
|
||||||
if args.legacy {
|
|
||||||
println!("[!] Legacy mode active.");
|
|
||||||
print!("{}", set_local_db(start, &config)?);
|
|
||||||
toggle_index(start, &config)?;
|
|
||||||
} else {
|
|
||||||
println!("[!] Legacy mode deactivated.");
|
|
||||||
}
|
|
||||||
|
|
||||||
print!("{}", set_dot_env(start, &config)?);
|
if start {
|
||||||
|
print_file_change(&set_local_db(start, &config)?, args.verbose);
|
||||||
|
}
|
||||||
|
toggle_index(start, &config)?;
|
||||||
|
if !start {
|
||||||
|
restore_connections_env(&config)?;
|
||||||
|
println!("[!] conf/global/connections.env restored to committed state.");
|
||||||
|
}
|
||||||
|
print_file_change(&set_dot_env(start, &config)?, args.verbose);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"[!] Don't forget your interpreter! Project directory: {}",
|
"[!] Don't forget your interpreter! Project directory: {}",
|
||||||
|
|||||||
Reference in New Issue
Block a user