Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/src/content/docs/step.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ Locked worktrees and the main worktree are always skipped. The current worktree

### Min-age guard

Candidates younger than `--min-age` (default: 1 day) are skipped. A worktree's age comes from its creation time, and a branch with no worktree takes its age from its oldest reflog entry. This prevents removing a worktree just created from the default branch: it looks "merged" because its branch points at the same commit.
Candidates younger than `--min-age` (default: 1 day) are skipped. A worktree's age comes from its creation time. A branch with no worktree takes its age from its oldest reflog entry, or, when it has none (common in bare repositories), from when git last wrote its ref. This prevents removing a worktree just created from the default branch: it looks "merged" because its branch points at the same commit.

```console
$ wt step prune --min-age=0s # no age guard
Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/step.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/step.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/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ Locked worktrees and the main worktree are always skipped. The current worktree

## Min-age guard

Candidates younger than `--min-age` (default: 1 day) are skipped. A worktree's age comes from its creation time, and a branch with no worktree takes its age from its oldest reflog entry. This prevents removing a worktree just created from the default branch: it looks "merged" because its branch points at the same commit.
Candidates younger than `--min-age` (default: 1 day) are skipped. A worktree's age comes from its creation time. A branch with no worktree takes its age from its oldest reflog entry, or, when it has none (common in bare repositories), from when git last wrote its ref. This prevents removing a worktree just created from the default branch: it looks "merged" because its branch points at the same commit.

```console
$ wt step prune --min-age=0s # no age guard
Expand Down
87 changes: 59 additions & 28 deletions src/commands/step/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ fn check_one(
let age = if min_age_duration > Duration::ZERO {
match &item.source {
CheckSource::Linked { wt_idx } => worktree_age(repo, &worktrees[*wt_idx], now_secs)?,
CheckSource::Orphan => orphan_branch_age(repo, &item.integration_ref, now_secs),
CheckSource::Orphan => Some(orphan_branch_age(repo, &item.integration_ref, now_secs)?),
CheckSource::Prunable { .. } => None,
}
} else {
Expand Down Expand Up @@ -697,34 +697,65 @@ fn worktree_age(
/// `--date=unix` renders each entry's selector as `<name>@{<epoch>}`, and git
/// 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". `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> {
/// A branch with no reflog entries is aged by [`ref_write_epoch`] instead.
/// `core.logAllRefUpdates` defaults to whether the repository is bare as seen
/// from where git runs, so in a bare repository only a ref written from inside
/// a linked worktree gets a reflog: `git clone --bare`, a `git fetch` into
/// `refs/heads/*`, and `wt switch --create` run from the bare directory all
/// leave none. Expiry (`git gc`) can also empty a reflog.
fn orphan_branch_age(repo: &Repository, branch: &str, now_secs: u64) -> anyhow::Result<Duration> {
let ref_name = format!("refs/heads/{branch}");
let stdout = repo
.run_command(&[
"reflog",
"show",
"--no-show-signature",
"--date=unix",
"--format=%gd",
&ref_name,
])
.ok()?;
let created_epoch = stdout
.trim()
.lines()
.last()
.and_then(|selector| selector.rsplit_once("@{"))
.and_then(|(_, epoch)| epoch.strip_suffix('}'))
.and_then(|epoch| epoch.parse::<u64>().ok())?;
Some(Duration::from_secs(now_secs.saturating_sub(created_epoch)))
let stdout = repo.run_command(&[
"reflog",
"show",
"--no-show-signature",
"--date=unix",
"--format=%gd",
&ref_name,
"--",
])?;
let created_epoch = match stdout.trim().lines().last() {
Some(selector) => selector
.rsplit_once("@{")
.and_then(|(_, epoch)| epoch.strip_suffix('}'))
.and_then(|epoch| epoch.parse::<u64>().ok())
.with_context(|| format!("parsing reflog selector {selector:?}"))?,
None => ref_write_epoch(repo, branch)?,
};
Ok(Duration::from_secs(now_secs.saturating_sub(created_epoch)))
}

/// When git last wrote the storage holding a branch's ref, as a Unix epoch.
///
/// Git replaces ref storage by renaming a new file into place, so a file's
/// mtime is its last write, and the branch it holds existed by then. An age
/// measured from it is never older than the branch, so the guard errs toward
/// keeping it. Which file holds the ref depends on the backend:
///
/// - **files** (the default): a loose `refs/heads/<branch>` shadows
/// `packed-refs`. Creating or updating a ref writes it loose, including from
/// `git fetch`, while `git clone` writes `packed-refs`. `git pack-refs` (run
/// by `git gc`) and deleting any packed ref rewrite `packed-refs` whole,
/// which makes every packed branch young again and delays its pruning by up
/// to `--min-age`.
/// - **reftable**: tables hold many refs, and every ref update rewrites
/// `reftable/tables.list`, so a branch counts as no older than the
/// repository's last ref update.
fn ref_write_epoch(repo: &Repository, branch: &str) -> anyhow::Result<u64> {
let common_dir = repo.git_common_dir();
let metadata = match repo.config_value("extensions.refStorage")?.as_deref() {
Some("reftable") => fs::metadata(common_dir.join("reftable").join("tables.list")),
_ => match fs::metadata(common_dir.join("refs/heads").join(branch)) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
fs::metadata(common_dir.join("packed-refs"))
Comment thread
worktrunk-bot marked this conversation as resolved.
Outdated
}
loose => loose,
},
};
let written = metadata
.and_then(|m| m.modified())
.with_context(|| format!("reading when branch {branch} was last written"))?;
Ok(written.duration_since(std::time::UNIX_EPOCH)?.as_secs())
}

/// Render dry-run output (text or JSON) and the `Skipped (younger than ...)`
Expand Down
57 changes: 56 additions & 1 deletion tests/integration_tests/step_prune.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! Integration tests for `wt step prune`

use crate::common::{
BareRepoTest, TestRepo, make_snapshot_cmd, repo, repo_with_remote, setup_temp_snapshot_settings,
BareRepoTest, TEST_EPOCH, TestRepo, make_snapshot_cmd, repo, repo_with_remote,
setup_temp_snapshot_settings,
};
use ansi_str::AnsiStr;
use insta::assert_snapshot;
Expand Down Expand Up @@ -420,6 +421,60 @@ fn test_prune_orphan_branch_min_age(repo: TestRepo) {
assert_cmd_snapshot!(cmd);
}

/// Orphan branches without a reflog are aged by when their ref was last written.
///
/// `git clone --bare` writes no reflogs and stores the branches it brings down
/// in `packed-refs`; a later fetch into `refs/heads/*` writes a new branch as a
/// loose ref, also without a reflog. The two files are dated a month and an
/// hour before TEST_EPOCH, so each branch's age shows which file it was read
/// from: `cloned` is a candidate, and `fetched` is skipped as younger than 1d.
#[rstest]
fn test_prune_orphan_branch_min_age_without_reflog(repo: TestRepo) {
repo.create_branch("cloned");
let clone = repo.home_path().join("clone.git");
repo.run_git(&["clone", "--bare", ".", clone.to_str().unwrap()]);
repo.run_git_in(
&clone,
&[
"config",
"remote.origin.fetch",
"+refs/heads/*:refs/heads/*",
],
);
repo.create_branch("fetched");
repo.run_git_in(&clone, &["fetch", "origin"]);

for branch in ["cloned", "fetched"] {
let ref_name = format!("refs/heads/{branch}");
let output = repo
.git_command()
.args(["reflog", "exists", &ref_name])
.current_dir(&clone)
.run()
.unwrap();
assert!(!output.status.success(), "{branch} should have no reflog");
}

let set_age = |path: &std::path::Path, age_secs: u64| {
let written = std::time::UNIX_EPOCH + std::time::Duration::from_secs(TEST_EPOCH - age_secs);
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(written)
.unwrap();
};
set_age(&clone.join("packed-refs"), 30 * 24 * 60 * 60);
set_age(&clone.join("refs/heads/fetched"), 60 * 60);

assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--dry-run"],
Some(&clone)
));
}

/// Prune can remove a mix of branch-only and worktree candidates in one run.
#[rstest]
fn test_prune_mixed_worktree_and_orphan_branch(mut repo: TestRepo) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
source: tests/integration_tests/step_prune.rs
info:
program: wt
args:
- step
- prune
- "--dry-run"
env:
APPDATA: "[TEST_CONFIG_HOME]"
CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]"
CLICOLOR_FORCE: "1"
COLUMNS: "500"
GIT_ALLOW_PROTOCOL: file
GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z"
GIT_AUTHOR_EMAIL: test@example.com
GIT_AUTHOR_NAME: Test User
GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z"
GIT_COMMITTER_EMAIL: test@example.com
GIT_COMMITTER_NAME: Test User
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig
GIT_CONFIG_KEY_0: user.useConfigOnly
GIT_CONFIG_KEY_1: rerere.enabled
GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig
GIT_CONFIG_VALUE_0: "true"
GIT_CONFIG_VALUE_1: "false"
GIT_TERMINAL_PROMPT: "0"
HOME: "[TEST_HOME]"
LANG: C
LC_ALL: C
LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]"
OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]"
PATH: "[PATH]"
TERM: alacritty
USERPROFILE: "[TEST_HOME]"
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_CODEX_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]"
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_PROBE_TIMEOUT_MS: "60000"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]"
---
success: true
exit_code: 0
----- stdout -----
○ cloned (branch only) — same commit as main
↳ 1 branch would be removed (dry run)

----- stderr -----
○ Skipped fetched (younger than 1d)
Loading