Compare commits

...
7 Commits
Author SHA1 Message Date
mathias e97205c4bf Hide changed connection lines behind -v/--verbose
set_local_db/set_dot_env diffs can contain secrets (connection
strings), so printing them unconditionally on every up/down leaked
credentials to stdout. Default output is now a line-count summary;
pass -v/--verbose to see the actual before/after lines.
2026-09-18 17:59:55 +02:00
mathias 6b6c031242 Restore connections.env without triggering post-checkout hooks
git checkout fires post-checkout even for a single-file checkout
(flag=0), which was tripping an rsync hook in the target repo. Read
the committed blob with git cat-file instead and write it directly -
plumbing, no worktree update, no hooks - with a test that plants a
post-checkout hook and asserts it never runs.
2026-09-17 16:07:48 +02:00
mathias 9f4a0dc446 Preserve trailing newlines and restore connections.env via git checkout on down
Fix set_local_db/set_dot_env unconditionally stripping (then forcing)
the file's trailing newline: they now detect whether the source file
originally ended in a newline and reproduce that on write, verified
against both the newline and no-newline cases.

Replace the reverse-toggle computation for connections.env on `down`
with a straight `git checkout`, so the file always ends up
byte-for-byte identical to HEAD instead of relying on toggle logic to
get it exactly right. Both git call sites now go through a shared
helper that checks the process exit status and errors out instead of
silently reporting success on a failed checkout/update-index, with
test coverage against real temporary git repos.
2026-09-17 15:44:14 +02:00
mathias 3873987c19 Always update connections.env and fix double-toggled connection line
Drop the --legacy flag: set_local_db and toggle_index now run
unconditionally instead of only when -l was passed, since that was the
flag's only effect.

Fix set_dot_env writing the connection/RCC_CONNECTION line twice with
opposing polarity (once from the #docker pass, once from the #cidb
pass), which flipped it to the wrong value. Collapsed the three
separate open/read/write passes into one, toggling both sections and
the connection line in a single scan so the connection line is only
ever touched once.
2026-09-17 15:12:32 +02:00
mathias 36752606a6 stop git-checkout from reverting connections.env on down
toggle_index ran git checkout on connections.env whenever going
down, discarding whatever set_local_db had just written and
restoring whatever format happens to be committed. This silently
undid the RCC_/legacy dual-format toggle. update-index still marks
the file assume-unchanged/no-assume-unchanged; the checkout is gone.
2026-09-17 11:33:49 +02:00
mathias 72e0c229f3 report which lines changed on up/down
set_local_db, toggle_after_line and set_dot_env now return a
FileChange (path + before/after line pairs) instead of (), and main
prints it after each toggle so 'up'/'down' shows exactly what the
program rewrote in each env file.
2026-09-17 11:00:17 +02:00
mathias 12b8701e00 support RCC_-prefixed env keys alongside legacy dotted keys
connections.env and local .env are transitioning from dotted keys
(ci.db.master.ip, connection) to upper-cased RCC_-prefixed keys
(RCC_CI_DB_MASTER_IP, RCC_CONNECTION). Both forms are matched and
toggled during the migration, so old branches keep working while
new ones use the new format.
2026-09-17 10:43:13 +02:00
5 changed files with 786 additions and 93 deletions
+6 -6
View File
@@ -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
View File
@@ -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,
} }
} }
+519 -61
View File
@@ -1,7 +1,5 @@
use std::{ use std::fmt;
fs::{self, File}, use std::fs;
io::{BufRead, BufReader},
};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -9,99 +7,215 @@ use crate::env::config::DevToolsConf;
pub const GLOBAL_CONNECTION_PATH: &str = "conf/global/connections.env"; pub const GLOBAL_CONNECTION_PATH: &str = "conf/global/connections.env";
const LOCAL_CONNECTION_PATH: &str = "conf/local/.env"; const LOCAL_CONNECTION_PATH: &str = "conf/local/.env";
/// The lines a toggle operation rewrote in a given file, as (before, after) pairs.
pub struct FileChange {
pub path: String,
pub diffs: Vec<(String, String)>,
}
impl FileChange {
fn no_changes_message(&self) -> String {
format!("[!] {}: no changes", self.path)
}
}
impl fmt::Display for FileChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.diffs.is_empty() {
return writeln!(f, "{}", self.no_changes_message());
}
writeln!(f, "[!] {} changed:", self.path)?;
for (before, after) in &self.diffs {
writeln!(f, " {} -> {}", before, after)?;
}
Ok(())
}
}
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<()> { pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
let global_connections_env = format!( let global_connections_env = format!(
"{}{}", "{}{}",
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();
for line in reader.lines() { let mut diffs: Vec<(String, String)> = Vec::new();
let line_content = line?; for line_content in content.lines() {
let modified = if start && connections_env_comment(&line_content) { let modified: Option<String> = if start && connections_env_comment(line_content) {
format!("# {}", line_content) Some(format!("# {}", line_content))
} else if !start && should_commented_out(&line_content) { } else if !start && should_commented_out(line_content) {
line_content[2..].to_string() Some(line_content[2..].to_string())
} else { } else {
line_content None
}; };
modified_lines.push(modified);
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()),
}
} }
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 {
Ok(()) output.push('\n');
}
fs::write(&global_connections_env, output).context("Cannot write global connections env")?;
Ok(FileChange {
path: global_connections_env,
diffs,
})
} }
pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<()> { const DOCKER_SECTION: &str = "#docker";
toggle_after_line("#docker", 4, start, config)?; const DOCKER_SECTION_LINES: usize = 4;
toggle_after_line("#cidb", 2, !start, config)?; const CIDB_SECTION: &str = "#cidb";
Ok(()) const CIDB_SECTION_LINES: usize = 2;
}
pub(crate) fn connections_env_comment(line: &str) -> bool { /// Toggles the `#docker` and `#cidb` sections and the `connection` line of
line.starts_with("ci.db.master.ip=") || line.starts_with("ci.db.master.port=") /// `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
pub(crate) fn should_commented_out(line: &str) -> bool { /// and the connection moves to `ci`; `down` is the reverse, ending on
line.starts_with("# ci.db.master.ip=") || line.starts_with("# ci.db.master.port=") /// `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> {
fn toggle_after_line(
target_line_prefix: &str,
num_lines: usize,
activate: bool,
config: &DevToolsConf,
) -> Result<()> {
let local_connection_env = format!( let local_connection_env = format!(
"{}{}", "{}{}",
config.project_dir_as_string(), config.project_dir_as_string(),
LOCAL_CONNECTION_PATH LOCAL_CONNECTION_PATH
); );
let file: File = File::open(&local_connection_env) let content = fs::read_to_string(&local_connection_env)
.with_context(|| format!("could not read file `{}`", local_connection_env))?; .with_context(|| format!("could not read file `{}`", local_connection_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 relevant = false; let mut diffs: Vec<(String, String)> = Vec::new();
let mut counter: usize = 0; let mut section: Option<(bool, usize)> = None;
for line in reader.lines() { for line_content in content.lines() {
let line_content = line?; let trimmed = line_content.trim_start();
if counter == num_lines { if let Some((_, remaining)) = section {
relevant = false; if remaining == 0 {
section = None;
}
} }
if line_content.trim_start().starts_with(target_line_prefix) {
relevant = true; if trimmed.starts_with(DOCKER_SECTION) {
modified_lines.push(line_content); 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; continue;
} }
if relevant { let modified: Option<String> = if let Some((activate, remaining)) = &mut section {
modified_lines.push(toggle(&line_content, activate)); let new_line = toggle(line_content, *activate);
counter += 1; *remaining -= 1;
(new_line != line_content).then_some(new_line)
} else { } else {
if !activate && line_content.trim_start().starts_with("connection=") { connection_key(line_content).and_then(|key| {
modified_lines.push("connection='ci'".to_string()); let value = if start { "ci" } else { "staging" };
continue; let new_line = format!("{}='{}'", key, value);
} else if activate && line_content.trim_start().starts_with("connection=") { (new_line != line_content).then_some(new_line)
modified_lines.push("connection='staging'".to_string()); })
continue; };
match modified {
Some(new_line) => {
diffs.push((line_content.to_string(), new_line.clone()));
modified_lines.push(new_line);
} }
modified_lines.push(line_content); None => modified_lines.push(line_content.to_string()),
} }
} }
fs::write(local_connection_env, modified_lines.join("\n")) let mut output = modified_lines.join("\n");
.context("Cannot write local connection env")?; if trailing_newline {
output.push('\n');
}
fs::write(&local_connection_env, output).context("Cannot write local connection env")?;
Ok(()) Ok(FileChange {
path: local_connection_env,
diffs,
})
}
const CI_DB_MASTER_IP: &str = "ci.db.master.ip";
const CI_DB_MASTER_PORT: &str = "ci.db.master.port";
const CONNECTION: &str = "connection";
const RCC_CI_DB_MASTER_IP: &str = "RCC_CI_DB_MASTER_IP";
const RCC_CI_DB_MASTER_PORT: &str = "RCC_CI_DB_MASTER_PORT";
const RCC_CONNECTION: &str = "RCC_CONNECTION";
/// Converts a legacy dotted key (`ci.db.master.ip`) into its `RCC_`-prefixed
/// replacement (`RCC_CI_DB_MASTER_IP`), as used by the new env file format.
/// Test-only: keeps the `RCC_*` consts above honest (see `rcc_key_matches_const`)
/// — the hot paths below use the consts directly to avoid allocating per line.
#[cfg(test)]
fn rcc_key(dotted_key: &str) -> String {
format!("RCC_{}", dotted_key.to_uppercase().replace('.', "_"))
}
fn starts_with_env_key(line: &str, key: &str) -> bool {
line.strip_prefix(key)
.is_some_and(|rest| rest.starts_with('='))
}
pub(crate) fn connections_env_comment(line: &str) -> bool {
starts_with_env_key(line, CI_DB_MASTER_IP)
|| starts_with_env_key(line, RCC_CI_DB_MASTER_IP)
|| starts_with_env_key(line, CI_DB_MASTER_PORT)
|| starts_with_env_key(line, RCC_CI_DB_MASTER_PORT)
}
pub(crate) fn should_commented_out(line: &str) -> bool {
match line.strip_prefix("# ") {
Some(rest) => connections_env_comment(rest),
None => false,
}
}
/// 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.
fn connection_key(line: &str) -> Option<&'static str> {
let trimmed = line.trim_start();
if starts_with_env_key(trimmed, CONNECTION) {
Some(CONNECTION)
} else if starts_with_env_key(trimmed, RCC_CONNECTION) {
Some(RCC_CONNECTION)
} else {
None
}
} }
pub(crate) fn toggle(line: &str, activate: bool) -> String { pub(crate) fn toggle(line: &str, activate: bool) -> String {
@@ -119,6 +233,299 @@ pub(crate) fn toggle(line: &str, activate: bool) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::ops::Deref;
use std::path::{Path, PathBuf};
/// An isolated `conf/global`/`conf/local` tree under the OS temp dir, for
/// exercising `set_local_db`/`set_dot_env` against real files without
/// touching this repo's own `conf/` directory. Namespaced by process id
/// so concurrent `cargo test` runs can't collide on the same path, and
/// removed on drop so no stale directories accumulate.
struct TempProjectDir(PathBuf);
impl TempProjectDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"dev_tools_parse_test_{}_{}",
std::process::id(),
name
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("conf/global")).unwrap();
fs::create_dir_all(dir.join("conf/local")).unwrap();
Self(dir)
}
}
impl Deref for TempProjectDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for TempProjectDir {
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 set_local_db_records_diffs_when_commenting_out() {
let dir = TempProjectDir::new("comment_out");
fs::write(
dir.join(GLOBAL_CONNECTION_PATH),
"ci.db.master.ip=127.0.0.1\nci.db.master.port=5432\nci.db.master.user=admin\nRCC_CI_DB_MASTER_IP=127.0.0.1",
)
.unwrap();
let change = set_local_db(true, &config_for(&dir)).unwrap();
assert_eq!(
change.diffs,
vec![
(
"ci.db.master.ip=127.0.0.1".to_string(),
"# ci.db.master.ip=127.0.0.1".to_string()
),
(
"ci.db.master.port=5432".to_string(),
"# ci.db.master.port=5432".to_string()
),
(
"RCC_CI_DB_MASTER_IP=127.0.0.1".to_string(),
"# RCC_CI_DB_MASTER_IP=127.0.0.1".to_string()
),
]
);
}
#[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]
fn set_local_db_records_diffs_when_uncommenting() {
let dir = TempProjectDir::new("uncomment");
fs::write(
dir.join(GLOBAL_CONNECTION_PATH),
"# ci.db.master.ip=127.0.0.1\n# ci.db.master.port=5432\nci.db.master.user=admin",
)
.unwrap();
let change = set_local_db(false, &config_for(&dir)).unwrap();
assert_eq!(
change.diffs,
vec![
(
"# ci.db.master.ip=127.0.0.1".to_string(),
"ci.db.master.ip=127.0.0.1".to_string()
),
(
"# ci.db.master.port=5432".to_string(),
"ci.db.master.port=5432".to_string()
),
]
);
}
#[test]
fn set_dot_env_keeps_trailing_newline_when_modifying() {
let dir = TempProjectDir::new("trailing_newline_local_modify");
let path = dir.join(LOCAL_CONNECTION_PATH);
fs::write(&path, "connection='ci'\n").unwrap();
set_dot_env(false, &config_for(&dir)).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "connection='staging'\n");
}
#[test]
fn set_dot_env_preserves_trailing_newline_when_unmodified() {
let dir = TempProjectDir::new("trailing_newline_local_noop");
let path = dir.join(LOCAL_CONNECTION_PATH);
fs::write(&path, "OTHER_KEY=value\n").unwrap();
set_dot_env(true, &config_for(&dir)).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "OTHER_KEY=value\n");
}
#[test]
fn set_dot_env_preserves_missing_trailing_newline() {
let dir = TempProjectDir::new("no_trailing_newline_local");
let path = dir.join(LOCAL_CONNECTION_PATH);
fs::write(&path, "OTHER_KEY=value").unwrap();
set_dot_env(true, &config_for(&dir)).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "OTHER_KEY=value");
}
#[test]
fn set_dot_env_merges_diffs_from_both_sections() {
let dir = TempProjectDir::new("merge_sections");
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\nci.db.master.ip=127.0.0.1\nci.db.master.port=5432\nconnection='staging'\nRCC_CONNECTION='staging'",
)
.unwrap();
let change = set_dot_env(true, &config_for(&dir)).unwrap();
assert!(change.path.ends_with(LOCAL_CONNECTION_PATH));
assert_eq!(change.diffs.len(), 6);
for expected in [
("#DOCKER_TLS_VERIFY=1", "DOCKER_TLS_VERIFY=1"),
("#DOCKER_CERT_PATH=/certs", "DOCKER_CERT_PATH=/certs"),
("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='staging'", "connection='ci'"),
("RCC_CONNECTION='staging'", "RCC_CONNECTION='ci'"),
] {
assert!(
change
.diffs
.contains(&(expected.0.to_string(), expected.1.to_string())),
"missing diff {:?}",
expected
);
}
}
#[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]
fn file_change_display_no_diffs() {
let change = FileChange {
path: "some/path".to_string(),
diffs: vec![],
};
assert_eq!(format!("{}", change), "[!] some/path: no changes\n");
}
#[test]
fn file_change_display_with_diffs() {
let change = FileChange {
path: "some/path".to_string(),
diffs: vec![(
"connection='ci'".to_string(),
"connection='staging'".to_string(),
)],
};
assert_eq!(
format!("{}", change),
"[!] some/path changed:\n connection='ci' -> connection='staging'\n"
);
}
#[test]
fn file_change_display_with_multiple_diffs() {
let change = FileChange {
path: "some/path".to_string(),
diffs: vec![
("a=1".to_string(), "#a=1".to_string()),
("b=2".to_string(), "#b=2".to_string()),
],
};
assert_eq!(
format!("{}", change),
"[!] some/path changed:\n a=1 -> #a=1\n b=2 -> #b=2\n"
);
}
#[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() {
@@ -169,4 +576,55 @@ mod tests {
fn should_commented_out_ignores_uncommented() { fn should_commented_out_ignores_uncommented() {
assert!(!should_commented_out("ci.db.master.ip=127.0.0.1")); assert!(!should_commented_out("ci.db.master.ip=127.0.0.1"));
} }
#[test]
fn rcc_key_converts_dotted_key() {
assert_eq!(rcc_key("ci.db.master.ip"), "RCC_CI_DB_MASTER_IP");
assert_eq!(rcc_key("connection"), "RCC_CONNECTION");
}
#[test]
fn rcc_key_matches_const() {
assert_eq!(rcc_key(CI_DB_MASTER_IP), RCC_CI_DB_MASTER_IP);
assert_eq!(rcc_key(CI_DB_MASTER_PORT), RCC_CI_DB_MASTER_PORT);
assert_eq!(rcc_key(CONNECTION), RCC_CONNECTION);
}
#[test]
fn connections_env_comment_matches_rcc_ip() {
assert!(connections_env_comment("RCC_CI_DB_MASTER_IP=127.0.0.1"));
}
#[test]
fn connections_env_comment_matches_rcc_port() {
assert!(connections_env_comment("RCC_CI_DB_MASTER_PORT=5432"));
}
#[test]
fn should_commented_out_matches_rcc_ip() {
assert!(should_commented_out("# RCC_CI_DB_MASTER_IP=127.0.0.1"));
}
#[test]
fn should_commented_out_matches_rcc_port() {
assert!(should_commented_out("# RCC_CI_DB_MASTER_PORT=5432"));
}
#[test]
fn connection_key_matches_legacy() {
assert_eq!(connection_key("connection='ci'"), Some("connection"));
}
#[test]
fn connection_key_matches_rcc() {
assert_eq!(
connection_key("RCC_CONNECTION='ci'"),
Some("RCC_CONNECTION")
);
}
#[test]
fn connection_key_ignores_other() {
assert_eq!(connection_key("connection_pool=5"), None);
}
} }
+197 -11
View File
@@ -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 { /// Restores `connections.env` to its committed state, used on `down` instead
Command::new("git") /// of computing the reverse toggle so the file ends up byte-for-byte
.current_dir(config.project_dir_as_string()) /// identical to HEAD, with no risk of a parsing mismatch. Reads the
.args(["checkout", &project_path]) /// committed blob with `git cat-file` and writes it directly rather than
.status()?; /// 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
View File
@@ -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.");
set_local_db(start, &config)?;
toggle_index(start, &config)?;
} else {
println!("[!] Legacy mode deactivated.");
}
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: {}",