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
66 changes: 54 additions & 12 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ filterset = "test(/aur_pkgbuild/)"

[package]
name = "worktrunk"
version = "0.77.0"
version = "0.78.0"
edition = "2024"
rust-version = "1.97"
build = "build.rs"
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ A branch checked out in a second worktree is retained regardless, `-D` included.
- `wt merge` / `wt step push` — the target branch's checked-out worktree is updated to the merged commits, so a file those commits delete disappears from it, and an ignored file at a path they track is overwritten — the same result a `git merge` run in that worktree would produce. Uncommitted changes at paths the merge doesn't touch stay in place, staged or not; one at a path it does touch refuses the merge upfront, naming the file
- `wt remove` — besides the worktree being removed, two cleanup mechanisms run. The removed worktree's own `git fsmonitor--daemon` (git's per-worktree filesystem watcher under `core.fsmonitor=true`, which would leak once its worktree is gone) is sent `git fsmonitor--daemon stop`, then force-terminated (`SIGTERM`, then `SIGKILL`) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes `.git/wt/trash/` entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans from `git worktree remove`, `rm -rf`, or a crashed `wt`)
- `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash)
- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `<config-dir>/vendor/autoload` (now `<data-dir>/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed
- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `<config-dir>/vendor/autoload` (now `<data-dir>/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed. The same rule covers the path being installed to, so an existing `functions/wt.fish`, `completions/wt.fish`, or `wt.nu` is replaced whole; [Files created](/shell-integration/#files-created) names the file each shell gets
- `wt config shell uninstall` — removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fish `functions/`, `conf.d/`, and `completions/`; nushell `vendor/autoload`). Uninstall takes no command name, so it lists those directories and recognizes files by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone. An rc file belongs to the user, so a line qualifies only where it runs the init command: one that merely mentions it, inside a comment, an `echo`, or an alias body, stays. Every line uninstall does take is printed, before removal and again after
- `wt config plugins opencode uninstall` / `wt config plugins pi uninstall` / `wt config plugins omp uninstall` — deletes that agent's `worktrunk.ts` plugin file. Only worktrunk's own file is touched; the rest of the agent's plugin directory is left alone

Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/faq.md

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

2 changes: 1 addition & 1 deletion skills/worktrunk/reference/faq.md

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

11 changes: 7 additions & 4 deletions src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,10 @@ $ wt config plugins opencode install --yes
## Plugin location

The plugin is written to `~/.config/opencode/plugins/worktrunk.ts`,
mirroring OpenCode's own global-config precedence:
`$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > `~/.config/opencode`."#
following OpenCode's global-config precedence:
`$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > `~/.config/opencode`.
An empty `$OPENCODE_CONFIG_DIR` and an empty or relative `$XDG_CONFIG_HOME`
are ignored."#
)]
Install,

Expand Down Expand Up @@ -465,9 +467,10 @@ $ wt config plugins opencode uninstall

## Plugin location

Written to `~/.config/opencode/plugins/worktrunk.ts`. Honors OpenCode's
Written to `~/.config/opencode/plugins/worktrunk.ts`. Follows OpenCode's
config precedence: `$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` >
`~/.config/opencode`."#
`~/.config/opencode`. An empty `$OPENCODE_CONFIG_DIR` and an empty or relative
`$XDG_CONFIG_HOME` are ignored."#
)]
Opencode {
#[command(subcommand)]
Expand Down
11 changes: 7 additions & 4 deletions src/commands/step/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,10 +698,13 @@ fn worktree_age(
/// forbids `@{` inside a ref name, so the last `@{` opens the timestamp.
///
/// Returns `None` if the reflog is missing or unparsable — callers treat
/// "unknown age" as "old enough". A bare repository defaults
/// `core.logAllRefUpdates` to false, so a branch created or updated only from
/// the bare directory itself has no reflog and no min-age guard; one touched
/// from inside a linked worktree has a reflog.
/// "unknown age" as "old enough". `core.logAllRefUpdates` defaults to whether
/// the repository is bare as seen from where the command runs, so a ref
/// written from a bare directory has no reflog and no min-age guard:
/// `git clone --bare` writes none for the branches it brings down, and
/// neither does a later `git fetch` into `refs/heads/*`. A linked worktree is
/// not bare, so a branch created or updated from one — every branch `wt`
/// itself creates — is aged normally.
fn orphan_branch_age(repo: &Repository, branch: &str, now_secs: u64) -> Option<Duration> {
let ref_name = format!("refs/heads/{branch}");
let stdout = repo
Expand Down
23 changes: 18 additions & 5 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ pub fn is_user_project_override_key(key: &str) -> bool {
.any(|k| k == key)
}

/// Replace a key's inline-table value with a standard table, carrying the key's
/// leading decor onto the table header.
/// Replace a key's inline-table value with a standard table, carrying the line's
/// comments onto the table header.
///
/// The key was parsed from `merge = { … }`, so its leaf decor holds whatever
/// preceded the line — comments, blank lines — plus the space before `=`. A
Expand All @@ -165,12 +165,16 @@ pub fn is_user_project_override_key(key: &str) -> bool {
/// and the user's own comment is what breaks it. Move the prefix to the header
/// and drop the rest.
///
/// A trailing comment after the closing brace sits in the inline value's own
/// decor, which `InlineTable::into_table` discards, so it is read from
/// `existing` before the replacement and lands after the header's `]`. It is
/// carried only when it holds a comment; bare whitespace there would just trail
/// the header.
///
/// Both places that rewrite a table the user wrote inline go through here — the
/// save-path merge in `user::persistence`, and `ensure_standard_table_parent`
/// in `deprecation`, which has no choice but to convert because TOML forbids
/// extending an inline table with a later subtable. The value's own decor (a
/// trailing comment after the closing brace) is still dropped by
/// `InlineTable::into_table`.
/// extending an inline table with a later subtable.
pub(crate) fn replace_inline_with_table(
existing: &mut toml_edit::Table,
key: &str,
Expand All @@ -184,6 +188,15 @@ pub(crate) fn replace_inline_with_table(
if let Some(prefix) = prefix {
table.decor_mut().set_prefix(prefix);
}
let suffix = existing
.get(key)
.and_then(|item| item.as_inline_table())
.and_then(|inline| inline.decor().suffix())
.filter(|suffix| suffix.as_str().is_some_and(|s| s.contains('#')))
.cloned();
if let Some(suffix) = suffix {
table.decor_mut().set_suffix(suffix);
}
if let Some(mut key_mut) = existing.key_mut(key) {
key_mut.leaf_decor_mut().clear();
}
Expand Down
24 changes: 23 additions & 1 deletion src/config/user/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ impl UserConfig {
}
Some(existing_item) => {
if !Self::items_equal(existing_item, desired_item) {
*existing_item = desired_item.clone();
Self::replace_keeping_decor(existing_item, desired_item);
}
}
None => {
Expand All @@ -112,6 +112,28 @@ impl UserConfig {
}
}

/// Overwrite an item, keeping the value's own decor — the spacing after `=`
/// and the trailing comment after the value.
///
/// Comments and blank lines *above* the line sit on the key's leaf decor,
/// which a value replacement never touches. The trailing comment sits on the
/// value, so replacing the item wholesale drops it: whenever a save changes
/// a value, and — since template-variable migration became `Structural` —
/// on a line the command never touched, because every load rewrites retired
/// names and the next unrelated mutation (declining the commit-generation
/// offer, say) finds that line changed. An inline table turning into a
/// standard one takes the other path, `replace_inline_with_table`, which
/// moves decor onto the header.
fn replace_keeping_decor(existing_item: &mut toml_edit::Item, desired_item: &toml_edit::Item) {
let decor = existing_item.as_value().map(|v| v.decor().clone());
*existing_item = desired_item.clone();
if let Some(decor) = decor
&& let Some(value) = existing_item.as_value_mut()
{
*value.decor_mut() = decor;
}
}

/// Compare two Items for value equality, ignoring formatting and comments.
fn items_equal(a: &toml_edit::Item, b: &toml_edit::Item) -> bool {
match (a, b) {
Expand Down
23 changes: 19 additions & 4 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1339,7 +1339,17 @@ pub fn add_pty_filters(settings: &mut insta::Settings) {
// stdout (`exec 2>&1`) so that leaked job-control *notifications* are
// visible to tests — those stay visible, since `assert_no_job_control_messages`
// matches the `[1] 12345` / `[1]+ Done` shape this filter does not touch.
settings.add_filter(r"(?m)^\w+: child setpgid \(\d+ to \d+\): [^\n]*\n?", "");
//
// The shell writes the line whenever the race resolves, so it need not
// start a line: it has also landed straight after the capture's trailing
// `\x1b[0m`, with no newline between (macOS CI, actions/runs/35132033122).
// The pattern therefore starts at a line start or right after an SGR
// escape, and keeps the escape. A bare unanchored `\w+` would instead eat
// the tail of whatever word preceded the shell's name.
settings.add_filter(
r"(?m)(^|\x1b\[[0-9;]*m)\w+: child setpgid \(\d+ to \d+\): [^\n]*\n?",
"$1",
);
}

/// Add filters for binary paths (target/debug/wt) in PTY output.
Expand Down Expand Up @@ -1368,13 +1378,15 @@ pub fn add_pty_binary_path_filters(settings: &mut insta::Settings) {
// =============================================================================

/// PTY capture carrying a shell's failed-`setpgid` diagnostic alongside the
/// job-control notifications the wrapper tests assert on.
/// job-control notifications the wrapper tests assert on — once at a line
/// start, and once appended to the capture's trailing SGR reset.
#[cfg(test)]
const SETPGID_NOISE_SAMPLE: &str = r#"bash: child setpgid (42242 to 42242): Operation not permitted
const SETPGID_NOISE_SAMPLE: &str = "bash: child setpgid (42242 to 42242): Operation not permitted
[1] 42243
Switched to worktree for feature-api
[1]+ Done wt hook post-start
"#;
\x1b[0mbash: child setpgid (64019 to 64019): Operation not permitted
";

#[cfg(test)]
mod tests {
Expand All @@ -1389,11 +1401,14 @@ mod tests {
fn pty_filters_drop_the_setpgid_diagnostic_but_keep_job_control_notices() {
let mut settings = insta::Settings::clone_current();
add_pty_filters(&mut settings);
// Runs after the PTY filters, so it shows the reset they must keep.
settings.add_filter(r"\x1b\[0m", "[RESET]");
settings.bind(|| {
assert_snapshot!(SETPGID_NOISE_SAMPLE, @r"
[1] 42243
Switched to worktree for feature-api
[1]+ Done wt hook post-start
[RESET]
");
});
}
Expand Down
Loading
Loading