Skip to content

Commit 82ca17e

Browse files
authored
feat(bootstrap): expose resource provenance (#12100)
1 parent 8c5c9a2 commit 82ca17e

14 files changed

Lines changed: 312 additions & 37 deletions

File tree

docs/bootstrap/files.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,11 @@ change.
9797
`mise bootstrap plan` includes these resources and automatically orders a
9898
managed file after its managed parent directory. Removal reverses that
9999
dependency so children are removed before their parent.
100+
101+
The JSON output from `mise bootstrap plan`, `mise bootstrap status`, and
102+
`mise bootstrap files status` includes an `origin` object for managed files
103+
and directories. It identifies the declaring config, that config's
104+
`config_root`, any configuration environment encoded by its filename, and a
105+
resolved source path when the file uses `source`.
106+
Paths are ordinary strings when they are valid UTF-8. On Unix, a path containing
107+
non-UTF-8 bytes uses `mise:path-bytes:<base64url>` so provenance remains lossless.

docs/dotfiles.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ that declares the entry, so a global `~/.config/mise/config.toml` can manage
5959
dotfiles kept next to it, and a project config can ship machine setup from
6060
the repo.
6161

62+
`mise bootstrap dotfiles status --json` includes an `origin` object for every
63+
entry. It reports the declaring config, its `config_root`, any configuration
64+
environment encoded by that config filename, and the resolved source path.
65+
Paths are ordinary strings when they are valid UTF-8. On Unix, a path containing
66+
non-UTF-8 bytes uses `mise:path-bytes:<base64url>` so provenance remains lossless.
67+
This makes layered dotfile declarations inspectable without reconstructing
68+
their precedence by hand.
69+
6270
Use `content` to declare a literal whole file inline instead of keeping a
6371
separate source file. Inline content is written as a private regular file
6472
(`0600` on Unix) and cannot be combined with `source`, `mode`, or `exclude`:

e2e/cli/test_bootstrap_secrets

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,20 @@ fi
88
secret_value="mise-bootstrap-secret-7f9d3a"
99
secret_env="MISE_BOOTSTRAP_TEST_SECRET"
1010
managed_file="$PWD/secret.conf"
11+
template_source="$PWD/secret.conf.tmpl"
1112
static_file="$PWD/static.conf"
1213
static_dir="$PWD/static-dir"
1314
managed_owner="$(id -un)"
1415
managed_group="$(id -gn)"
1516

1617
unset "$secret_env"
18+
echo 'token={{ secret(name="cache_token") }}' >"$template_source"
1719
cat <<EOF >mise.toml
1820
[bootstrap.secrets]
1921
cache_token = { env = "$secret_env", description = "Cache token" }
2022
2123
[bootstrap.files."$managed_file"]
22-
content = 'token={{ secret(name="cache_token") }}'
24+
source = "secret.conf.tmpl"
2325
template = true
2426
owner = "$managed_owner"
2527
group = "$managed_group"
@@ -56,6 +58,8 @@ assert_contains "mise bootstrap plan" "$managed_file"
5658
assert_contains "mise bootstrap plan" "$static_file"
5759
assert_contains "mise bootstrap plan" "$static_dir"
5860
assert_contains "mise bootstrap plan" "unknown"
61+
assert_succeed "mise bootstrap plan --json | jq -e '.resources[] | select(.id.name == \"$managed_file\") | .origin.source == \"$template_source\"'"
62+
assert_succeed "mise bootstrap files status --json | jq -e '.[] | select(.id.name == \"$managed_file\") | .origin.source == \"$template_source\"'"
5963
assert_fail "mise bootstrap plan --detailed-exitcode"
6064
assert_succeed "mise bootstrap --only repos --dry-run"
6165

e2e/cli/test_bootstrap_system_files

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ assert "cat $managed_file" "first"
5151
assert "stat -c %a $managed_nested" "750"
5252
assert_succeed "mise bootstrap files status --missing"
5353
assert_succeed "mise bootstrap files status --json | jq -e . >/dev/null"
54+
assert_succeed "mise bootstrap files status --json | jq -e '.[] | select(.id.kind == \"file\") | .origin.config == \"$PWD/mise.toml\" and .origin.config_root == \"$PWD\"'"
55+
assert_succeed "mise bootstrap plan --json | jq -e '.resources[] | select(.id.kind == \"file\") | .origin.config == \"$PWD/mise.toml\" and .origin.config_root == \"$PWD\"'"
5456
assert_contains "mise bootstrap status --json" '"files"'
5557

5658
# Applying the same declarations again is a no-op.

e2e/cli/test_dotfiles_files

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ EOF
2727
assert_contains "mise bootstrap dotfiles status" "missing"
2828
assert_contains "MISE_EXPERIMENTAL=0 mise dotfiles status" "missing"
2929
assert_fail "mise dotfiles status --missing"
30+
assert_succeed "mise dotfiles status --json | jq -e '.files[] | select(.target == \"~/.gitconfig\") | .origin.config == \"$PWD/mise.toml\" and .origin.config_root == \"$PWD\" and .origin.source == \"$PWD/dotfiles/gitconfig\"'"
31+
32+
cat <<EOF >mise.dev.toml
33+
[dotfiles]
34+
"~/.dev-origin" = { content = "dev" }
35+
EOF
36+
assert_succeed "MISE_ENV=dev mise dotfiles status --json | jq -e '.files[] | select(.target == \"~/.dev-origin\") | .origin.environment == [\"dev\"]'"
3037

3138
# system commands do not manage dotfiles
3239
assert_succeed "mise bootstrap packages apply --yes"

src/cli/bootstrap.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,13 +1939,21 @@ impl BootstrapPlan {
19391939
} else if output.resources.is_empty() {
19401940
info!("nothing configured for bootstrap planning");
19411941
} else {
1942-
let mut table = MiseTable::new(false, &["Action", "Resource", "Current", "Desired"]);
1942+
let mut table = MiseTable::new(
1943+
false,
1944+
&["Action", "Resource", "Current", "Desired", "Config"],
1945+
);
19431946
for resource in &output.resources {
19441947
table.add_row(vec![
19451948
resource.action.to_string(),
19461949
resource.id.to_string(),
19471950
resource.current.clone(),
19481951
resource.desired.clone(),
1952+
resource
1953+
.origin
1954+
.as_ref()
1955+
.map(|origin| origin.config.display_user())
1956+
.unwrap_or_default(),
19491957
]);
19501958
}
19511959
table.print()?;
@@ -2138,13 +2146,20 @@ impl BootstrapFilesStatus {
21382146
} else if resources.is_empty() {
21392147
info!("no system files or directories configured");
21402148
} else {
2141-
let mut table = MiseTable::new(false, &["Action", "Resource", "Current", "Desired"]);
2149+
let mut table = MiseTable::new(
2150+
false,
2151+
&["Action", "Resource", "Current", "Desired", "Config"],
2152+
);
21422153
for resource in resources {
21432154
table.add_row(vec![
21442155
resource.action.to_string(),
21452156
resource.id.to_string(),
21462157
resource.current,
21472158
resource.desired,
2159+
resource
2160+
.origin
2161+
.map(|origin| origin.config.display_user())
2162+
.unwrap_or_default(),
21482163
]);
21492164
}
21502165
table.print()?;

src/cli/dotfiles/add.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,12 @@ impl PlannedAdd {
392392
.parent()
393393
.unwrap_or(std::path::Path::new("."))
394394
.to_path_buf(),
395+
origin: crate::system::resources::ResourceOrigin {
396+
config: config_path.to_path_buf(),
397+
config_root: crate::config::config_file::config_root::config_root(config_path),
398+
environment: crate::config::environments_for_config_path(config_path),
399+
source: Some(self.source.clone()),
400+
},
395401
}
396402
}
397403
}

src/cli/dotfiles/status.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ impl DotfilesStatus {
5858
"source": (req.mode != system::files::FileMode::Content)
5959
.then(|| req.source.display_user()),
6060
"mode": req.mode.name(),
61+
"origin": &req.origin,
6162
"state": match &state {
6263
FileState::Applied => "applied",
6364
FileState::Missing => "missing",
@@ -74,6 +75,7 @@ impl DotfilesStatus {
7475
} else {
7576
req.source.display_user()
7677
},
78+
req.origin.config.display_user(),
7779
state_str,
7880
]);
7981
}
@@ -113,6 +115,7 @@ impl DotfilesStatus {
113115
json_edits.push(json!({
114116
"path": req.path_raw,
115117
"edit": req.describe_op(),
118+
"origin": &req.origin,
116119
"state": match &state {
117120
FileState::Applied => "applied",
118121
FileState::Missing => "missing",
@@ -121,7 +124,12 @@ impl DotfilesStatus {
121124
},
122125
}));
123126
} else {
124-
edit_rows.push(vec![req.path_raw.clone(), req.describe_op(), state_str]);
127+
edit_rows.push(vec![
128+
req.path_raw.clone(),
129+
req.describe_op(),
130+
req.origin.config.display_user(),
131+
state_str,
132+
]);
125133
}
126134
}
127135

@@ -143,14 +151,15 @@ impl DotfilesStatus {
143151
info!("nothing configured in [dotfiles]");
144152
}
145153
if !file_rows.is_empty() {
146-
let mut table = MiseTable::new(false, &["Target", "Mode", "Source", "State"]);
154+
let mut table =
155+
MiseTable::new(false, &["Target", "Mode", "Source", "Config", "State"]);
147156
for row in file_rows {
148157
table.add_row(row);
149158
}
150159
table.print()?;
151160
}
152161
if !edit_rows.is_empty() {
153-
let mut table = MiseTable::new(false, &["File", "Edit", "State"]);
162+
let mut table = MiseTable::new(false, &["File", "Edit", "Config", "State"]);
154163
for row in edit_rows {
155164
table.add_row(row);
156165
}

src/config/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,25 @@ pub(crate) fn config_paths_in_dir(dir: &Path) -> Vec<PathBuf> {
17211721
config_paths_in_dir_with_filenames(dir, &DEFAULT_CONFIG_FILENAMES)
17221722
}
17231723

1724+
/// Return the active configuration environment encoded in a loaded config
1725+
/// filename. A vector matches the ordered MISE_ENV model and leaves room for
1726+
/// future config forms that represent more than one environment.
1727+
pub(crate) fn environments_for_config_path(path: &Path) -> Vec<String> {
1728+
let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
1729+
return vec![];
1730+
};
1731+
env::MISE_ENV_WITH_AUTO
1732+
.iter()
1733+
.filter(|environment| {
1734+
["config", "mise", ".mise"].into_iter().any(|prefix| {
1735+
filename == format!("{prefix}.{environment}.toml")
1736+
|| filename == format!("{prefix}.{environment}.local.toml")
1737+
})
1738+
})
1739+
.cloned()
1740+
.collect()
1741+
}
1742+
17241743
fn config_paths_in_dir_with_filenames(dir: &Path, filenames: &[String]) -> Vec<PathBuf> {
17251744
let config_paths: Vec<PathBuf> = filenames
17261745
.iter()

src/system/edits.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::config::{Config, ConfigMap};
3434
use crate::file;
3535
use crate::path::PathExt;
3636
use crate::system::files::FileState;
37+
use crate::system::resources::ResourceOrigin;
3738
use crate::ui::prompt;
3839

3940
/// one `[dotfiles]` edit entry as written in mise.toml. Operations stay loosely typed so configs using operations
@@ -107,6 +108,7 @@ pub struct EditRequest {
107108
pub base: PathBuf,
108109
/// config file that declared this edit
109110
pub config_path: PathBuf,
111+
pub origin: ResourceOrigin,
110112
}
111113

112114
impl EditRequest {
@@ -228,6 +230,12 @@ fn resolve_entry(
228230
"\"{path_raw}\".{id:?}: ids may only contain letters, digits, '_', '-', and '.', ignoring entry"
229231
);
230232
}
233+
let mut origin = ResourceOrigin {
234+
config: config_path.to_path_buf(),
235+
config_root: crate::config::config_file::config_root::config_root(config_path),
236+
environment: crate::config::environments_for_config_path(config_path),
237+
source: None,
238+
};
231239
let entry = match entry {
232240
EditTomlEntry::Block(inline) => EditTomlTable {
233241
block: Some(inline),
@@ -260,11 +268,13 @@ fn resolve_entry(
260268
(Some(inline), None) => BlockSource::Inline(inline),
261269
(None, Some(src)) => {
262270
let src = file::replace_path(&src);
263-
BlockSource::File(if src.is_relative() {
271+
let src = if src.is_relative() {
264272
base.join(src)
265273
} else {
266274
src
267-
})
275+
};
276+
origin.source = Some(src.clone());
277+
BlockSource::File(src)
268278
}
269279
(None, None) => unreachable!("is_block"),
270280
};
@@ -305,6 +315,7 @@ fn resolve_entry(
305315
op,
306316
base: base.to_path_buf(),
307317
config_path: config_path.to_path_buf(),
318+
origin,
308319
})
309320
}
310321

0 commit comments

Comments
 (0)