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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ When no structured alternative exists, document the fragility inline.

### Network Access

worktrunk is local-first: the network is touched only when the user asked for it, and only where reaching the wire directly serves that request. **One detection helper is exempt:** the *first* `Repository::default_branch()` per repo may fall through to `git ls-remote`; the result caches in `worktrunk.default-branch` and every later call is local. No other detection helper may add a similar fallback.
worktrunk is local-first: the network is touched only when the user asked for it, and only where reaching the wire directly serves that request. **One detection helper is exempt:** the *first* `Repository::default_branch()` per repo may fall through to `git ls-remote`; the result caches in `worktrunk.default-branch` and every later call is local. The query is bounded by `REMOTE_DETECTION_TIMEOUT` — nothing in git bounds it, and an unreachable host costs ~127 s per address on Linux — and a query that hits the bound falls back to local inference *without* caching it, so an outage can't make a guess permanent. No other detection helper may add a similar fallback.

Why: silent "lookup" paths that walk to the wire (alias dispatch, hook context build, recovery) stall commands the user wouldn't expect to do network work, worst on a fresh clone. The `default_branch()` bootstrap keeps a fresh clone usable while bounding the exception to one helper firing at most once per repo.

Expand All @@ -125,7 +125,7 @@ Why: wt installs a `signal_hook` SIGINT/SIGTERM handler so it can forward signal

- Signal-derived child exits surface structurally: stream mode (`Cmd::stream`) as `WorktrunkError::ChildProcessExited { signal: Some(sig), .. }`, capture mode (`Cmd::run`) as `CommandError { signal: Some(sig), .. }`. These fields are the structured channel — never sniff `code >= 128` or parse error messages.
- Detect via `err.interrupt_signal()` (the `worktrunk::git::ErrorExt` trait). When it returns `Some(signal)`, propagate as `WorktrunkError::Interrupted { signal, hint }` and break the loop. `Interrupted` exits `128 + signal` (130 SIGINT, 143 SIGTERM) and renders once, at exit, per shell convention: silent for SIGINT (the terminal echoed `^C`), `Terminated` for SIGTERM — the line the shell would print if wt weren't trapping the signal. `hint` carries an optional recovery line for state the interrupt left behind (e.g. a mid-rebase worktree).
- In capture mode only SIGINT/SIGTERM classify as interrupts. Capture children get no forwarding or escalation, and their captured output would be discarded by the silent exit — so a child killed by any other signal (a crash, an OOM kill) surfaces as a visible error instead. Stream mode counts any signal: output already streamed to the terminal, and user-initiated kills are normalized upstream to the originating SIGINT/SIGTERM (`seen_signal` in `shell_exec`, the concurrent runner's originating-signal override).
- In capture mode only SIGINT/SIGTERM classify as interrupts. Capture children get no forwarding or escalation, and their captured output would be discarded by the silent exit — so a child killed by any other signal (a crash, an OOM kill) surfaces as a visible error instead. A capture child with a `Cmd::timeout` is the one the tty broadcast doesn't reach: it runs in its own process group so expiry can tear down its whole tree (`run_with_timeout_impl`), which is what makes the bound bound anything, so a Ctrl-C during one waits out the remaining timeout. Stream mode counts any signal: output already streamed to the terminal, and user-initiated kills are normalized upstream to the originating SIGINT/SIGTERM (`seen_signal` in `shell_exec`, the concurrent runner's originating-signal override).
- The check happens **before** any `FailureStrategy` branch — Warn must NOT swallow signal-derived errors.
- `handle_command_error` in `src/commands/command_executor.rs` enforces this for hook and alias pipelines (foreground and concurrent groups); `for_each.rs` enforces it for the worktree loop. New code that loops over child processes calls `.interrupt_signal()` on per-iteration errors and breaks.

Expand Down
6 changes: 4 additions & 2 deletions docs/content/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1026,11 +1026,13 @@ Worktrunk detects the default branch automatically:

1. **Worktrunk cache** — Checks `git config worktrunk.default-branch`
2. **Git cache** — Detects primary remote and checks its HEAD (e.g., `origin/HEAD`)
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s
4. **Local inference** — If no remote, infers from local branches
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s, abandoned after 10s
4. **Local inference** — If no remote, or the query was abandoned, infers from local branches

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.

An abandoned remote query is the one case that isn't cached: the branch it inferred locally answers that command, but a value guessed while the remote was unreachable would otherwise become permanent, so the next command queries again.

The local inference fallback uses these heuristics in order:
- If only one local branch exists, uses it
- For bare repos or empty repos, checks `symbolic-ref HEAD`
Expand Down
6 changes: 4 additions & 2 deletions plugins/worktrunk/skills/worktrunk/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1065,11 +1065,13 @@ Worktrunk detects the default branch automatically:

1. **Worktrunk cache** — Checks `git config worktrunk.default-branch`
2. **Git cache** — Detects primary remote and checks its HEAD (e.g., `origin/HEAD`)
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s
4. **Local inference** — If no remote, infers from local branches
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s, abandoned after 10s
4. **Local inference** — If no remote, or the query was abandoned, infers from local branches

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.

An abandoned remote query is the one case that isn't cached: the branch it inferred locally answers that command, but a value guessed while the remote was unreachable would otherwise become permanent, so the next command queries again.

The local inference fallback uses these heuristics in order:
- If only one local branch exists, uses it
- For bare repos or empty repos, checks `symbolic-ref HEAD`
Expand Down
6 changes: 4 additions & 2 deletions skills/worktrunk/reference/config.md

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

6 changes: 4 additions & 2 deletions src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,11 +807,13 @@ Worktrunk detects the default branch automatically:

1. **Worktrunk cache** — Checks `git config worktrunk.default-branch`
2. **Git cache** — Detects primary remote and checks its HEAD (e.g., `origin/HEAD`)
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s
4. **Local inference** — If no remote, infers from local branches
3. **Remote query** — If not cached, queries `git ls-remote` — typically 100ms–2s, abandoned after 10s
4. **Local inference** — If no remote, or the query was abandoned, infers from local branches

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.

An abandoned remote query is the one case that isn't cached: the branch it inferred locally answers that command, but a value guessed while the remote was unreachable would otherwise become permanent, so the next command queries again.

The local inference fallback uses these heuristics in order:
- If only one local branch exists, uses it
- For bare repos or empty repos, checks `symbolic-ref HEAD`
Expand Down
13 changes: 3 additions & 10 deletions src/git/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,10 +755,7 @@ mod tests {
)
.unwrap();
let git = |dir: &Path| {
Cmd::new("git")
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", "/dev/null")
crate::testing::configure_git_env(Cmd::new("git"), &gitconfig).current_dir(dir)
};

let main = tmp.path().join("repo");
Expand Down Expand Up @@ -817,10 +814,8 @@ mod tests {
.unwrap();
let main = tmp.path().join("repo");
std::fs::create_dir(&main).unwrap();
Cmd::new("git")
crate::testing::configure_git_env(Cmd::new("git"), &gitconfig)
.current_dir(&main)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args(["init", "-b", "main"])
.run()
.unwrap();
Expand Down Expand Up @@ -854,10 +849,8 @@ mod tests {
.unwrap();
let main = tmp.path().join("repo");
std::fs::create_dir(&main).unwrap();
Cmd::new("git")
crate::testing::configure_git_env(Cmd::new("git"), &gitconfig)
.current_dir(&main)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args(["init", "-b", "main"])
.run()
.unwrap();
Expand Down
93 changes: 80 additions & 13 deletions src/git/repository/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Git config, hints, marker, and default branch operations for Repository.

use std::path::PathBuf;
use std::time::Duration;

use anyhow::Context;
use color_print::cformat;
Expand All @@ -11,6 +12,46 @@ use crate::git::CommandError;

use super::{DefaultBranchName, GitError, Repository};

/// How long `git ls-remote` may run before default-branch detection gives up.
///
/// The query normally answers in 100 ms–2 s, and until this bound nothing
/// limited how long it could take *not* to answer: an unanswered SYN costs
/// ~127 s per address on Linux (`tcp_syn_retries=6`) and git tries each of a
/// host's addresses in turn, so a remote behind a dropped VPN or a dead host
/// stalled `wt list --full` and `wt switch` for minutes. The bound is far
/// above a slow-but-working handshake and far below that, so it separates the
/// two cases without a judgement call.
const REMOTE_DETECTION_TIMEOUT: Duration = Duration::from_secs(10);

/// Outcome of the remote half of default-branch detection.
enum RemoteDetection {
/// The remote — or git's local `<remote>/HEAD` cache for it — named a branch.
Found(String),
/// There is no remote, or the query failed — no HEAD, no such repository,
/// an offline laptop. Whatever the local branches say stands in, and it is
/// cached: `ls-remote` exits 128 for all of those alike, so telling a
/// down network from a remote that simply has no HEAD would mean reading
/// git's error text, and re-querying on every command is the cost the
/// cache exists to avoid. Only a timeout separates cleanly, via
/// `ErrorKind::TimedOut`.
Unavailable,
/// The query hit [`REMOTE_DETECTION_TIMEOUT`]. The default branch is
/// unknown rather than absent, so local inference answers this invocation
/// but is not written to `worktrunk.default-branch`: a guess made while
/// the network was down would otherwise outlive the outage, and the
/// persisted cache is exactly what stops later calls from re-detecting.
TimedOut,
}

/// Whether a command failed by exceeding its [`crate::shell_exec::Cmd::timeout`].
///
/// `Cmd::run` reports the kill as an [`std::io::Error`], which `anyhow`
/// context wraps but preserves in the chain.
fn timed_out(err: &anyhow::Error) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::TimedOut)
}

impl Repository {
/// Get a git config value. Returns None if the key doesn't exist.
///
Expand Down Expand Up @@ -367,12 +408,15 @@ impl Repository {
/// Detection strategy:
/// 1. Check worktrunk cache (`git config worktrunk.default-branch`)
/// 2. Try primary remote's local cache (e.g., `origin/HEAD`)
/// 3. Query remote (`git ls-remote`) — may take 100 ms–2 s (sole wire fallback)
/// 3. Query remote (`git ls-remote`) — may take 100 ms–2 s (sole wire
/// fallback), bounded by `REMOTE_DETECTION_TIMEOUT`
/// 4. Infer from local branches if no remote
///
/// Detection results are cached to `worktrunk.default-branch` for future
/// calls. Result is also cached in the shared repo cache (shared across
/// all worktrees).
/// calls, except when step 3 timed out — see `RemoteDetection::TimedOut`.
/// Result is also cached in the shared repo cache (shared across all
/// worktrees), timeouts included: one invocation waits out the bound once,
/// not once per caller.
///
/// To minimize latency on the rare cold-clone case:
/// - Defer calling this until after fast, local checks (see e497f0f for an example).
Expand Down Expand Up @@ -400,14 +444,19 @@ impl Repository {
}

// Detect: try remote, then local inference
let detected = self.detect_from_remote().or_else(|| {
self.infer_default_branch_locally()
let remote = self.detect_from_remote();
let persist = !matches!(remote, RemoteDetection::TimedOut);
let detected = match remote {
RemoteDetection::Found(branch) => Some(branch),
RemoteDetection::Unavailable | RemoteDetection::TimedOut => self
.infer_default_branch_locally()
.inspect_err(|e| tracing::debug!(error = %e, "Local inference failed: {e}"))
.ok()
});
.ok(),
};

// Cache detected result to git config for future runs
if let Some(ref branch) = detected
if persist
&& let Some(ref branch) = detected
&& let Err(e) = self.set_config_value("worktrunk.default-branch", branch)
{
tracing::debug!(error = %e, "Failed to persist default-branch cache: {e}");
Expand All @@ -419,16 +468,31 @@ impl Repository {
}

/// Try to detect default branch from remote.
fn detect_from_remote(&self) -> Option<String> {
let remote = self.primary_remote().ok()?;
fn detect_from_remote(&self) -> RemoteDetection {
let Ok(remote) = self.primary_remote() else {
return RemoteDetection::Unavailable;
};

// Try git's local cache for this remote (e.g., origin/HEAD)
if let Ok(branch) = self.local_default_branch(&remote) {
return Some(branch);
return RemoteDetection::Found(branch);
}

// Query remote directly (may be slow)
self.query_remote_default_branch(&remote).ok()
match self.query_remote_default_branch(&remote) {
Ok(branch) => RemoteDetection::Found(branch),
Err(e) if timed_out(&e) => {
tracing::debug!(
"Remote default-branch query exceeded {REMOTE_DETECTION_TIMEOUT:?}; \
falling back to local inference without caching it"
);
RemoteDetection::TimedOut
}
Err(e) => {
tracing::debug!(error = %e, "Remote default-branch query failed: {e}");
RemoteDetection::Unavailable
}
}
}

/// Resolve a target branch from an optional override
Expand Down Expand Up @@ -584,7 +648,10 @@ impl Repository {
}

pub(super) fn query_remote_default_branch(&self, remote: &str) -> anyhow::Result<String> {
let stdout = self.run_command(&["ls-remote", "--symref", remote, "HEAD"])?;
let stdout = self.run_command_bounded(
&["ls-remote", "--symref", remote, "HEAD"],
Some(REMOTE_DETECTION_TIMEOUT),
)?;
DefaultBranchName::from_remote(&stdout).map(DefaultBranchName::into_string)
}

Expand Down
36 changes: 29 additions & 7 deletions src/git/repository/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1570,13 +1570,35 @@ impl Repository {
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn run_command(&self, args: &[&str]) -> anyhow::Result<String> {
let output = self
.with_object_store_env(
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context()),
)
self.run_command_bounded(args, None)
}

/// [`run_command`](Self::run_command) with an optional wall-clock bound.
///
/// A child still running when `timeout` expires is killed and the call
/// fails with [`std::io::ErrorKind::TimedOut`].
///
/// Local git commands pass `None`: they finish or fail on their own, so a
/// bound would only turn machine load into a spurious failure. The bound
/// is for the one git command in worktrunk that can reach the wire —
/// `ls-remote` in [`default_branch`](Self::default_branch), where nothing
/// else limits how long an unreachable host takes to not answer.
pub(super) fn run_command_bounded(
&self,
args: &[&str],
timeout: Option<std::time::Duration>,
) -> anyhow::Result<String> {
let mut cmd = self.with_object_store_env(
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context()),
);
if let Some(timeout) = timeout {
cmd = cmd.timeout(timeout);
}

let output = cmd
.run()
.with_context(|| format!("Failed to execute: git {}", args.join(" ")))?;

Expand Down
5 changes: 1 addition & 4 deletions src/git/repository/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1145,10 +1145,7 @@ fn prewarm_still_caches_preload_when_worktree_config_disabled() {
let gitconfig = tmp.path().join("test-gitconfig");
std::fs::write(&gitconfig, "[init]\n\tdefaultBranch = main\n").unwrap();

let out = Cmd::new("git")
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("LC_ALL", "C")
let out = crate::testing::configure_git_env(Cmd::new("git"), &gitconfig)
.args(["init", "-b", "main", root.to_str().unwrap()])
.run()
.unwrap();
Expand Down
Loading
Loading