Compare commits

...
5 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
5 changed files with 493 additions and 156 deletions
+6 -6
View File
@@ -35,14 +35,14 @@ cargo install --path .
## Usage
```
dev_tools <project> <up|down> [--legacy]
dev_tools <project> <up|down> [-v|--verbose]
```
| Argument | Description |
|---|---|
| `project` | Project key (matches `<PROJECT>_DIR` in the config, case-insensitive) |
| `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
@@ -53,15 +53,15 @@ dev_tools foo up
# Stop it
dev_tools foo down
# Start with legacy env handling
dev_tools foo up --legacy
# See exactly which lines were changed
dev_tools foo up -v
```
## What it does
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.
3. With `--legacy`: additionally modifies `conf/global/connections.env` and marks it with `git update-index --assume-unchanged` to avoid accidental commits.
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. Toggles database connection entries in `<PROJECT_DIR>/conf/local/.env`: `up` switches the connection to `ci`, `down` switches it to `staging`.
## Tests
+5 -5
View File
@@ -15,11 +15,11 @@ pub struct Arguments {
pub action: String,
#[arg(
short = 'l',
long = "legacy",
help = "Legacy mode for old env reading."
short,
long,
help = "Show which lines were changed (values may include secrets)"
)]
pub legacy: bool,
pub verbose: bool,
}
impl Arguments {
@@ -40,7 +40,7 @@ mod tests {
Arguments {
project: "test".to_string(),
action: action.to_string(),
legacy: false,
verbose: false,
}
}
+226 -124
View File
@@ -1,8 +1,5 @@
use std::{
fmt,
fs::{self, File},
io::{BufRead, BufReader},
};
use std::fmt;
use std::fs;
use anyhow::{Context, Result};
@@ -16,10 +13,16 @@ pub struct FileChange {
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, "[!] {}: no changes", self.path);
return writeln!(f, "{}", self.no_changes_message());
}
writeln!(f, "[!] {} changed:", self.path)?;
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
pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
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(),
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))?;
let reader: BufReader<File> = BufReader::new(file);
let trailing_newline = content.ends_with('\n');
let mut modified_lines: Vec<String> = Vec::new();
let mut diffs: Vec<(String, String)> = Vec::new();
for line in reader.lines() {
let line_content = line?;
let modified: Option<String> = if start && connections_env_comment(&line_content) {
for line_content in content.lines() {
let modified: Option<String> = if start && connections_env_comment(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())
} else {
None
@@ -54,26 +72,101 @@ pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
match modified {
Some(new_line) => {
diffs.push((line_content, new_line.clone()));
diffs.push((line_content.to_string(), new_line.clone()));
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"))
.context("Cannot write global connections env")?;
let mut output = modified_lines.join("\n");
if trailing_newline {
output.push('\n');
}
fs::write(&global_connections_env, output).context("Cannot write global connections env")?;
Ok(FileChange {
path: global_connections_env,
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> {
let mut docker_change = toggle_after_line("#docker", 4, start, config)?;
let cidb_change = toggle_after_line("#cidb", 2, !start, config)?;
docker_change.diffs.extend(cidb_change.diffs);
Ok(docker_change)
let local_connection_env = format!(
"{}{}",
config.project_dir_as_string(),
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";
@@ -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
/// legacy `connection` or the new `RCC_CONNECTION`), if the line sets it.
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]
fn set_local_db_records_diffs_when_uncommenting() {
let dir = TempProjectDir::new("uncomment");
@@ -304,55 +363,36 @@ mod tests {
}
#[test]
fn toggle_after_line_records_diffs_for_toggled_section() {
let dir = TempProjectDir::new("toggle_section");
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\nother=1",
)
.unwrap();
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();
let change = toggle_after_line("#docker", 4, true, &config_for(&dir)).unwrap();
set_dot_env(false, &config_for(&dir)).unwrap();
assert_eq!(
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()
),
]
);
assert_eq!(fs::read_to_string(&path).unwrap(), "connection='staging'\n");
}
#[test]
fn toggle_after_line_records_diffs_for_both_connection_keys() {
let dir = TempProjectDir::new("connection_keys");
fs::write(
dir.join(LOCAL_CONNECTION_PATH),
"#docker\nline1\nline2\nline3\nline4\nconnection='staging'\nRCC_CONNECTION='staging'",
)
.unwrap();
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();
let change = toggle_after_line("#cidb", 2, false, &config_for(&dir)).unwrap();
set_dot_env(true, &config_for(&dir)).unwrap();
assert_eq!(
change.diffs,
vec![
(
"connection='staging'".to_string(),
"connection='ci'".to_string()
),
(
"RCC_CONNECTION='staging'".to_string(),
"RCC_CONNECTION='ci'".to_string()
),
]
);
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]
@@ -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]
fn file_change_display_no_diffs() {
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]
fn toggle_activate_removes_leading_hash() {
assert_eq!(toggle("#value=1", true), "value=1");
+197 -11
View File
@@ -1,9 +1,25 @@
use std::fs;
use std::process::Command;
use anyhow::Result;
use anyhow::{ensure, Context, Result};
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<()> {
let change: &str = match start {
true => "--assume-unchanged",
@@ -15,17 +31,187 @@ pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> {
GLOBAL_CONNECTION_PATH
);
Command::new("git")
.current_dir(config.project_dir_as_string())
.args(["update-index", change, &project_path])
.status()?;
if !start {
Command::new("git")
.current_dir(config.project_dir_as_string())
.args(["checkout", &project_path])
.status()?;
run_git(
&config.project_dir_as_string(),
&["update-index", change, &project_path],
)
}
/// 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(())
}
#[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::{
arguments::Arguments,
config::parse::{set_dot_env, set_local_db},
git::command::toggle_index,
config::parse::{set_dot_env, set_local_db, FileChange},
git::command::{restore_connections_env, toggle_index},
};
use clap::Parser;
@@ -15,6 +15,54 @@ mod container;
mod env;
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<()> {
let args: Arguments = Arguments::parse();
args.validate()?;
@@ -22,15 +70,16 @@ fn main() -> Result<()> {
let start: bool = args.action == "up";
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!(
"[!] Don't forget your interpreter! Project directory: {}",