diff --git a/docs/content/config.md b/docs/content/config.md index 9f39d534f8..c99f2c056c 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -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 diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index bcacb330d4..d8751415e4 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -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 diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index bcacb330d4..d8751415e4 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -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 diff --git a/src/cli/config.rs b/src/cli/config.rs index 62996cc060..cab117af4e 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -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 diff --git a/src/commands/config/state.rs b/src/commands/config/state.rs index 6ce680c960..7aa8e72089 100644 --- a/src/commands/config/state.rs +++ b/src/commands/config/state.rs @@ -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}; @@ -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 /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(); @@ -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, @@ -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 /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 {remote}/HEAD ({remote_head})" + )); + let hint = hint_message(cformat!( + "To adopt it, run wt config state default-branch set {remote_head}; to re-detect, run wt config state default-branch clear" + )); + writeln!(out, "{warning}\n{hint}")?; + } + } None => writeln!(out, "{}", format_with_gutter("(none)", None))?, } writeln!(out)?; diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index 2b7dad596c..91e42aa1f5 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -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 `/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 `/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 // ========================================================================= diff --git a/tests/integration_tests/config_state.rs b/tests/integration_tests/config_state.rs index f88d510cd7..2b76376e45 100644 --- a/tests/integration_tests/config_state.rs +++ b/tests/integration_tests/config_state.rs @@ -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 // ============================================================================ @@ -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": [] @@ -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", @@ -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": [] diff --git a/tests/snapshots/integration__integration_tests__config_state__state_show_default_branch_drift_warns.snap b/tests/snapshots/integration__integration_tests__config_state__state_show_default_branch_drift_warns.snap new file mode 100644 index 0000000000..aebde27a6e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__config_state__state_show_default_branch_drift_warns.snap @@ -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 @ +  (none) + +HOOK OUTPUT @ +  (none) + +DIAGNOSTIC @ +  (none) + +TRASH @ _REPO_/.git/wt/trash +  (none) diff --git a/tests/snapshots/integration__integration_tests__help__help_config_state_default_branch.snap b/tests/snapshots/integration__integration_tests__help__help_config_state_default_branch.snap index 55353ad52e..71b1bd0835 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_state_default_branch.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_state_default_branch.snap @@ -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" @@ -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