Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 73 additions & 39 deletions src/commands/config/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,20 @@ fn handle_config_show_json() -> anyhow::Result<()> {

let (project_path, project_config, project_identifier) = if let Ok(repo) = Repository::current()
{
let path = repo.project_config_path()?;
let config = repo.load_project_config()?;
let on_disk = repo.project_config_path()?;
// When config resolved but not from an existing on-disk file, it came
// from the object-store fallback (bare repo, default branch checked out
// in no worktree — #3461). Surface that revision spec as the source so
// `path`/`exists`/`config` agree, instead of pointing `path` at a
// missing file while `config` is populated.
let path = match &on_disk {
Some(p) if p.exists() => on_disk.clone(),
_ if config.is_some() => repo
.default_branch_project_config_content()
.map(|(_, spec)| spec),
_ => on_disk.clone(),
};
let identifier = repo.project_identifier().ok();
(
path,
Expand All @@ -128,7 +140,12 @@ fn handle_config_show_json() -> anyhow::Result<()> {
},
"project": {
"path": project_path,
"exists": project_path.as_ref().is_some_and(|p| p.exists()),
// Config source resolved — an on-disk file or the object-store
// fallback — iff `config` is populated. Keying `exists` off the
// loaded config (not `path.exists()`) keeps it consistent with
// `config` in the object-store case, where `path` is a revision
// spec with no file on disk.
"exists": project_config.is_some(),
"identifier": project_identifier,
"config": project_config,
},
Expand Down Expand Up @@ -798,46 +815,63 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> {
return Ok(());
}
};
let config_path = match repo.project_config_path()? {
Some(path) => path,
None => {
writeln!(
out,
"{}",
cformat!(
"<dim>{}</>",
format_heading("PROJECT CONFIG", Some("No project config"))
)
)?;
return Ok(());
// Helper: heading + project identifier, shared across the resolved and
// absent branches so their output stays uniform.
fn write_heading_and_identifier(
out: &mut String,
repo: &Repository,
source: &str,
) -> anyhow::Result<()> {
writeln!(out, "{}", format_heading("PROJECT CONFIG", Some(source)))?;
// Project identifier — used as the key for [projects."..."] sections in
// user config. Surface it here so users can find the right key without
// hand-deriving it from the remote URL.
if let Ok(project_id) = repo.project_identifier() {
let line = info_message(cformat!("Identifier: <bold>{project_id}</>"));
writeln!(out, "{line}")?;
}
};

writeln!(
out,
"{}",
format_heading(
"PROJECT CONFIG",
Some(&format!("@ {}", format_path_for_display(&config_path)))
)
)?;

// Project identifier — used as the key for [projects."..."] sections in
// user config. Surface it here so users can find the right key without
// hand-deriving it from the remote URL.
if let Ok(project_id) = repo.project_identifier() {
let line = info_message(cformat!("Identifier: <bold>{project_id}</>"));
writeln!(out, "{line}")?;
}

// Check if file exists
if !config_path.exists() {
writeln!(out, "{}", hint_message("Not found"))?;
return Ok(());
Ok(())
}

// Read and display the file contents
let contents = std::fs::read_to_string(&config_path).context("Failed to read config file")?;
// Resolve the effective config source, mirroring `ProjectConfig::load`: an
// on-disk `.config/wt.toml` when one exists, otherwise the committed
// default-branch config read from the object store (bare repo, default
// branch checked out in no worktree — #3461). Reading the raw text here
// rather than calling `load_project_config` keeps the deprecation and
// validation rendering below, which operates on the TOML source. Without
// this fallback, `config show` reports "Not found" while the hooks from the
// object-store config actually run — the opposite of reality.
let on_disk = repo.project_config_path()?;
let (config_path, contents) = match &on_disk {
Some(path) if path.exists() => {
let contents = std::fs::read_to_string(path).context("Failed to read config file")?;
let source = format!("@ {}", format_path_for_display(path));
write_heading_and_identifier(out, &repo, &source)?;
(path.clone(), contents)
}
_ => match repo.default_branch_project_config_content() {
Some((object_store_contents, spec)) => {
// `spec` is a git revision spec (`<default>:.config/wt.toml`),
// not a filesystem path — display it verbatim, tagged as the
// object-store source so it isn't mistaken for an on-disk file.
let source = format!("@ {} (from object store)", spec.to_string_lossy());
write_heading_and_identifier(out, &repo, &source)?;
(spec, object_store_contents)
}
None => {
// Neither an on-disk file nor a committed fallback resolved.
let Some(path) = on_disk else {
let heading = format_heading("PROJECT CONFIG", Some("No project config"));
writeln!(out, "{}", cformat!("<dim>{}</>", heading))?;
return Ok(());
};
let source = format!("@ {}", format_path_for_display(&path));
write_heading_and_identifier(out, &repo, &source)?;
writeln!(out, "{}", hint_message("Not found"))?;
return Ok(());
}
},
};

if contents.trim().is_empty() {
writeln!(out, "{}", hint_message("Empty file"))?;
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests/bare_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,107 @@ fn test_bare_repo_project_config_found_from_linked_worktree_when_primary_off_bra
);
}

#[test]
fn test_bare_repo_config_show_reflects_object_store_fallback() {
// Regression for #3476: `wt config show` must display the config that
// `load_project_config` actually resolves. In the #3461 state — a bare repo
// whose default branch (carrying `.config/wt.toml`) is checked out in no
// worktree — the config comes from the object store, but `config show`
// previously keyed off `project_config_path()` alone and reported the
// config as absent (`Not found` / `exists: false`) while its hooks run.
let test = BareRepoTest::new();

// Primary worktree on the default branch, carrying the project config.
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");

let config_dir = main_worktree.join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), "post-start = \"echo hi\"\n").unwrap();
let output = test
.git_command(&main_worktree)
.args(["add", ".config/wt.toml"])
.run()
.unwrap();
assert!(output.status.success());
test.commit_in(&main_worktree, "Add project config");

// A second linked worktree whose branch drops the config on disk, so
// resolution from inside it must fall through to the object-store read.
let other_worktree = test.create_worktree("other", "other");
let output = test
.git_command(&other_worktree)
.args(["rm", ".config/wt.toml"])
.run()
.unwrap();
assert!(
output.status.success(),
"git rm failed: {}",
String::from_utf8_lossy(&output.stderr)
);
test.commit_in(&other_worktree, "Remove project config on other branch");

// Park the primary off the default branch, so `main` is checked out nowhere.
let output = test
.git_command(&main_worktree)
.args(["checkout", "-b", "feature-x"])
.run()
.unwrap();
assert!(
output.status.success(),
"checkout -b feature-x failed: {}",
String::from_utf8_lossy(&output.stderr)
);

// JSON: `path`/`exists`/`config` must agree with the object-store source.
let mut cmd = test.wt_command();
cmd.args(["config", "show", "--format=json"])
.current_dir(&other_worktree);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
assert_eq!(
json["project"]["config"]["post-start"], "echo hi",
"config show must surface the object-store config that hooks run from, got: {}",
json["project"]
);
assert_eq!(
json["project"]["exists"], true,
"`exists` must be true when config resolved from the object store, got: {}",
json["project"]
);
assert_eq!(
json["project"]["path"], "main:.config/wt.toml",
"`path` must name the object-store revision spec, not a missing file, got: {}",
json["project"]
);

// Text: the object-store source is labeled and the config is dumped —
// never `Not found`.
let mut cmd = test.wt_command();
cmd.args(["config", "show"]).current_dir(&other_worktree);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("from object store") && stdout.contains("post-start"),
"config show text must label the object-store source and dump the config; stdout:\n{stdout}"
);
assert!(
!stdout.contains("Not found"),
"config show must not report the object-store config as `Not found`; stdout:\n{stdout}"
);
}

#[test]
fn test_bare_repo_project_config_found_with_dash_c_flag() {
// Regression test for #1691 (comment): project config in the primary worktree
Expand Down
Loading