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
2 changes: 1 addition & 1 deletion docs/content/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,7 @@ Worktrunk detects the default branch automatically:
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s
4. **Local inference** — If no remote, infers from local branches

Once detected, the result is cached in `worktrunk.default-branch` for fast access.
Once detected, the result is cached in `worktrunk.default-branch` for fast access. The cache isn't re-validated on every command, so a later change to `origin/HEAD` — a renamed default branch followed by `git remote set-head origin -a` — isn't picked up automatically. `wt config state` flags the drift when the cached value differs from the remote's local HEAD; `set` adopts the new branch and `clear` re-detects.

The local inference fallback uses these heuristics in order:
- If only one local branch exists, uses it
Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,7 @@ Worktrunk detects the default branch automatically:
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s
4. **Local inference** — If no remote, infers from local branches

Once detected, the result is cached in `worktrunk.default-branch` for fast access.
Once detected, the result is cached in `worktrunk.default-branch` for fast access. The cache isn't re-validated on every command, so a later change to `origin/HEAD` — a renamed default branch followed by `git remote set-head origin -a` — isn't picked up automatically. `wt config state` flags the drift when the cached value differs from the remote's local HEAD; `set` adopts the new branch and `clear` re-detects.

The local inference fallback uses these heuristics in order:
- If only one local branch exists, uses it
Expand Down
2 changes: 1 addition & 1 deletion skills/worktrunk/reference/config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ Worktrunk detects the default branch automatically:
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s
4. **Local inference** — If no remote, infers from local branches

Once detected, the result is cached in `worktrunk.default-branch` for fast access.
Once detected, the result is cached in `worktrunk.default-branch` for fast access. The cache isn't re-validated on every command, so a later change to `origin/HEAD` — a renamed default branch followed by `git remote set-head origin -a` — isn't picked up automatically. `wt config state` flags the drift when the cached value differs from the remote's local HEAD; `set` adopts the new branch and `clear` re-detects.

The local inference fallback uses these heuristics in order:
- If only one local branch exists, uses it
Expand Down
31 changes: 28 additions & 3 deletions src/commands/config/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ use path_slash::PathExt as _;
use worktrunk::git::{BranchRef, Repository, sha_cache};
use worktrunk::path::format_path_for_display;
use worktrunk::styling::{
eprintln, format_heading, format_with_gutter, info_message, println, success_message,
warning_message,
eprintln, format_heading, format_with_gutter, hint_message, info_message, println,
success_message, warning_message,
};

use crate::cli::{OutputFormat, SwitchFormat};
Expand Down Expand Up @@ -1219,6 +1219,11 @@ fn handle_state_show_json(repo: &Repository) -> anyhow::Result<()> {
// (see handle_state_show_table).
let default_branch = repo.cached_default_branch();

// Git's local <remote>/HEAD branch, for scripts that want to detect drift
// from the cache above. Local-only (no ls-remote); None when unset or no
// remote.
let remote_head_branch = repo.remote_head().map(|(_, branch)| branch);

// Get previous branch
let previous_branch = repo.switch_previous();

Expand Down Expand Up @@ -1278,6 +1283,7 @@ fn handle_state_show_json(repo: &Repository) -> anyhow::Result<()> {

let output = serde_json::json!({
"default_branch": default_branch,
"remote_head_branch": remote_head_branch,
"previous_branch": previous_branch,
"markers": markers,
"ci_status": ci_status,
Expand Down Expand Up @@ -1305,7 +1311,26 @@ fn handle_state_show_table(repo: &Repository) -> anyhow::Result<()> {
// or persist, or it would silently repopulate a just-cleared cache.
writeln!(out, "{}", format_heading("DEFAULT BRANCH", None))?;
match repo.cached_default_branch() {
Some(branch) => writeln!(out, "{}", format_with_gutter(&branch, None))?,
Some(branch) => {
writeln!(out, "{}", format_with_gutter(&branch, None))?;
// Flag drift between the persisted cache and git's <remote>/HEAD.
// Local-only (reads the symref, no network); fires only when both
// resolve and differ — e.g. after a default-branch rename plus
// `git remote set-head origin -a`, which the fast path in
// `default_branch()` never notices. The key doubles as a user
// override, so this surfaces only on inspection, never per-command.
if let Some((remote, remote_head)) = repo.remote_head()
&& remote_head != branch
{
let warning = warning_message(cformat!(
"Cached branch differs from <bold>{remote}/HEAD</> (<bold>{remote_head}</>)"
));
let hint = hint_message(cformat!(
"To adopt it, run <underline>wt config state default-branch set {remote_head}</>; to re-detect, run <underline>wt config state default-branch clear</>"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The writing-user-outputs skill flags this pattern: hints render as a separate message from the warning above, so "it" (the remote's HEAD branch named on the warning line) has no local referent — Avoid pronouns with cross-message referents … Don't use pronouns like "it" that refer to something mentioned in the error message. Naming {remote}/HEAD directly makes the hint self-contained (remote is already in scope):

Suggested change
"To adopt it, run <underline>wt config state default-branch set {remote_head}</>; to re-detect, run <underline>wt config state default-branch clear</>"
"To adopt <bold>{remote}/HEAD</>, run <underline>wt config state default-branch set {remote_head}</>; to re-detect, run <underline>wt config state default-branch clear</>"

));
writeln!(out, "{warning}\n{hint}")?;
}
}
None => writeln!(out, "{}", format_with_gutter("(none)", None))?,
}
writeln!(out)?;
Expand Down
21 changes: 21 additions & 0 deletions src/git/repository/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,27 @@ impl Repository {
.filter(|s| !s.is_empty())
}

/// Read the primary remote's locally-cached HEAD without touching the
/// network, as `(remote_name, branch)` — the branch `<remote>/HEAD`
/// resolves to (e.g. `("origin", "main")`).
///
/// Unlike [`default_branch`](Self::default_branch), this never queries the
/// remote (`git ls-remote`), never infers from local branches, and never
/// persists a result: it reports only git's own `<remote>/HEAD` symref.
/// Returns `None` when there is no primary remote or its HEAD is not set
/// locally (e.g. `git remote set-head` was never run).
///
/// State inspection (`wt config state`) uses this to flag when the
/// persisted `worktrunk.default-branch` cache has drifted from
/// `origin/HEAD` — e.g. after a default-branch rename followed by
/// `git remote set-head origin -a`, which the fast-path cache in
/// `default_branch()` won't otherwise notice.
pub fn remote_head(&self) -> Option<(String, String)> {
let remote = self.primary_remote().ok()?;
let branch = self.local_default_branch(&remote).ok()?;
Some((remote, branch))
}

// =========================================================================
// Project config
// =========================================================================
Expand Down
58 changes: 58 additions & 0 deletions tests/integration_tests/config_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,61 @@ fn test_state_clear_default_branch_empty(repo: TestRepo) {
assert_snapshot!(String::from_utf8_lossy(&output.stderr), @"○ No default branch cache to clear");
}

#[rstest]
fn test_state_show_default_branch_drift_warns(mut repo: TestRepo) {
// origin/HEAD points at main (the fixture default)...
repo.setup_remote("main");
// ...but the persisted cache still names a branch from before a rename.
// This is the shape after a GitHub default-branch rename + `git remote
// set-head origin -a`: the fast-path cache in default_branch() never
// re-validates against origin/HEAD, so `wt config state` calls it out.
repo.git_command()
.args(["config", "worktrunk.default-branch", "old-default"])
.run()
.unwrap();

let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
state_get_settings().bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}

#[rstest]
fn test_state_show_default_branch_no_drift_when_matching(mut repo: TestRepo) {
// Cache matches origin/HEAD — no warning, just the value.
repo.setup_remote("main");
repo.git_command()
.args(["config", "worktrunk.default-branch", "main"])
.run()
.unwrap();

let output = wt_state_get_cmd(&repo).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("DEFAULT BRANCH"));
// No drift → no divergence warning.
assert!(
!stdout.contains("differs from"),
"expected no drift warning when cache matches origin/HEAD:\n{stdout}"
);
}

#[rstest]
fn test_state_get_json_reports_remote_head_branch(mut repo: TestRepo) {
repo.setup_remote("main");
repo.git_command()
.args(["config", "worktrunk.default-branch", "old-default"])
.run()
.unwrap();

let output = wt_state_get_json_cmd(&repo).output().unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid json");
assert_eq!(json["default_branch"], "old-default");
assert_eq!(json["remote_head_branch"], "main");
}

// ============================================================================
// previous-branch
// ============================================================================
Expand Down Expand Up @@ -1850,6 +1905,7 @@ fn test_state_get_json_empty(repo: TestRepo) {
"markers": [],
"max_pr_number": null,
"previous_branch": null,
"remote_head_branch": null,
"summaries": [],
"trash": [],
"vars": []
Expand Down Expand Up @@ -1945,6 +2001,7 @@ fn test_state_get_json_comprehensive(repo: TestRepo) {
],
"max_pr_number": null,
"previous_branch": "feature",
"remote_head_branch": null,
"summaries": [
{
"branch": "feature",
Expand Down Expand Up @@ -2047,6 +2104,7 @@ fn test_state_get_json_with_logs(repo: TestRepo) {
"markers": [],
"max_pr_number": null,
"previous_branch": null,
"remote_head_branch": null,
"summaries": [],
"trash": [],
"vars": []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
source: tests/integration_tests/config_state.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
DEFAULT BRANCH
  old-default
▲ Cached branch differs from origin/HEAD (main)
↳ To adopt it, run wt config state default-branch set main; to re-detect, run wt config state default-branch clear

PREVIOUS BRANCH
  (none)

BRANCH MARKERS
  (none)

VARS
  (none)

CI STATUS CACHE
  (none)

SUMMARY CACHE
  (none)

GIT COMMANDS CACHE
  (none)

HINTS
  (none)

COMMAND LOG @ <PATH>
  (none)

HOOK OUTPUT @ <PATH>
  (none)

DIAGNOSTIC @ <PATH>
  (none)

TRASH @ _REPO_/.git/wt/trash
  (none)
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ info:
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_PARENT_SHELL: ""
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
Expand Down Expand Up @@ -82,7 +83,7 @@ Worktrunk detects the default branch automatically:
3. Remote query — If not cached, queries git ls-remote — typically 100ms–2s
4. Local inference — If no remote, infers from local branches

Once detected, the result is cached in worktrunk.default-branch for fast access.
Once detected, the result is cached in worktrunk.default-branch for fast access. The cache isn't re-validated on every command, so a later change to origin/HEAD — a renamed default branch followed by git remote set-head origin -a — isn't picked up automatically. wt config state flags the drift when the cached value differs from the remote's local HEAD; set adopts the new branch and clear re-detects.

The local inference fallback uses these heuristics in order:
- If only one local branch exists, uses it
Expand Down
Loading