Synced from monorepo
Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
|
|
@ -1975,6 +1975,7 @@ pub mod gc {
|
|||
#[test]
|
||||
fn is_pid_alive_false_for_reaped_child() {
|
||||
// A fully reaped child's pid is gone (ESRCH) and must read as dead.
|
||||
#[allow(clippy::disallowed_methods)] // test fixture; the test reaps it
|
||||
let mut child = std::process::Command::new("true")
|
||||
.spawn()
|
||||
.expect("spawn `true`");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Throttled automatic worktree GC (feature `metadata`).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
|
|
@ -9,7 +9,6 @@ use crate::CleanupReport;
|
|||
use crate::api::gc::{GcOptions, GcReport, age_path_enabled, gc_worktrees};
|
||||
use crate::db::{ListFilter, WorktreeDb, WorktreeKind, now_epoch_secs, resolve_grok_home};
|
||||
use crate::discovery::{RebuildReport, rebuild_worktree_db};
|
||||
use crate::git::checkout::git_command;
|
||||
|
||||
pub const META_LAST_AUTO_GC_AT: &str = "last_auto_gc_at";
|
||||
/// Independent throttle stamp for optional DB rebuild (not shared with GC).
|
||||
|
|
@ -58,7 +57,7 @@ pub struct WorktreeAutoGcLayer {
|
|||
pub dry_run: Option<bool>,
|
||||
pub include_orphan_snapshots: Option<bool>,
|
||||
pub max_age_by_kind: BTreeMap<WorktreeKind, Option<u64>>,
|
||||
/// Optional discovery rebuild + stale `.git/worktrees/` prune (default off).
|
||||
/// Optional discovery rebuild + grok-scoped stale `.git/worktrees/` scrub (default off).
|
||||
pub include_rebuild: Option<bool>,
|
||||
/// Independent rebuild throttle; absent ⇒ 24h.
|
||||
pub rebuild_min_interval_secs: Option<u64>,
|
||||
|
|
@ -459,7 +458,7 @@ pub fn maybe_auto_gc(db: &WorktreeDb, auto_opts: &AutoGcOptions) -> Result<AutoG
|
|||
|
||||
let (overlay, btrfs) = run_orphan_cleaners(dry_run, auto_opts.include_orphan_snapshots);
|
||||
|
||||
// Prune each full pass when opted in (cheap vs discovery; not rebuild-throttled).
|
||||
// Scrub each full pass when opted in (cheap vs discovery; not rebuild-throttled).
|
||||
let stale_registrations_cleaned = if include_rebuild && !dry_run {
|
||||
prune_stale_git_worktree_registrations(&prune_repos)
|
||||
} else {
|
||||
|
|
@ -633,65 +632,29 @@ fn collect_source_repos_for_prune(db: &WorktreeDb) -> BTreeSet<PathBuf> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Scrub stale grok-owned registrations from each known source repo,
|
||||
/// scoped to worktrees under the grok home to prove ownership (see
|
||||
/// [`crate::git::remove_stale_worktree_registrations`] for why a blanket
|
||||
/// `git worktree prune` is unsafe here).
|
||||
fn prune_stale_git_worktree_registrations(repos: &BTreeSet<PathBuf>) -> u64 {
|
||||
let Ok(grok_home) = resolve_grok_home() else {
|
||||
tracing::warn!("auto worktree registration scrub skipped: grok home unresolved");
|
||||
return 0;
|
||||
};
|
||||
let cleaned: u64 = repos
|
||||
.iter()
|
||||
.filter(|repo| repo.is_dir())
|
||||
.map(|repo| prune_stale_registrations_in_repo(repo))
|
||||
.map(|repo| crate::git::remove_stale_worktree_registrations_under(repo, &grok_home))
|
||||
.fold(0u64, u64::saturating_add);
|
||||
if cleaned > 0 {
|
||||
tracing::info!(
|
||||
stale_registrations_cleaned = cleaned,
|
||||
"auto worktree stale git registrations pruned"
|
||||
"auto worktree stale git registrations scrubbed"
|
||||
);
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
fn count_git_worktree_registrations(git_worktrees_dir: &Path) -> u64 {
|
||||
let Ok(entries) = std::fs::read_dir(git_worktrees_dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.count() as u64
|
||||
}
|
||||
|
||||
fn prune_stale_registrations_in_repo(source_repo: &Path) -> u64 {
|
||||
let git_worktrees = source_repo.join(".git").join("worktrees");
|
||||
let before = count_git_worktree_registrations(&git_worktrees);
|
||||
|
||||
let output = git_command()
|
||||
.args(["worktree", "prune"])
|
||||
.current_dir(source_repo)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let after = count_git_worktree_registrations(&git_worktrees);
|
||||
before.saturating_sub(after)
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
status = %o.status,
|
||||
stderr = %String::from_utf8_lossy(&o.stderr),
|
||||
"git worktree prune failed"
|
||||
);
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
error = %e,
|
||||
"git worktree prune failed to spawn"
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_orphan_cleaners(
|
||||
dry_run: bool,
|
||||
include_orphan_snapshots: bool,
|
||||
|
|
@ -735,6 +698,7 @@ pub fn maybe_auto_gc_default() -> Result<AutoGcReport> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::{WorktreeRecord, WorktreeStatus};
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
|
@ -2095,6 +2059,48 @@ mod tests {
|
|||
assert!(count_regs(&repo) < before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_foreign_registrations_outside_grok_home() {
|
||||
let _g = env_guard();
|
||||
clear_auto_gc_env();
|
||||
let fx = crate::db::GrokHomeFixture::new();
|
||||
let db = WorktreeDb::open(&fx.home).unwrap();
|
||||
let repo = fx.home.join("foreign-src");
|
||||
|
||||
let user_dir = tempfile::TempDir::new().unwrap();
|
||||
let user_wt = user_dir.path().join("user-wt");
|
||||
plant_stale_git_worktree(&repo, &user_wt);
|
||||
let before = count_regs(&repo);
|
||||
assert!(before >= 1);
|
||||
|
||||
let tracked = fx.home.join("foreign-tracked");
|
||||
std::fs::create_dir_all(&tracked).unwrap();
|
||||
let mut rec = make_rec("fk-t", tracked, WorktreeKind::Session, now_epoch_secs());
|
||||
rec.source_repo = repo.clone();
|
||||
db.register(&rec).unwrap();
|
||||
|
||||
let report = maybe_auto_gc(
|
||||
&db,
|
||||
&AutoGcOptions {
|
||||
min_interval_secs: 0,
|
||||
include_orphan_snapshots: false,
|
||||
include_rebuild: true,
|
||||
rebuild_min_interval_secs: 0,
|
||||
..AutoGcOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report.stale_registrations_cleaned, 0,
|
||||
"foreign registrations must never be scrubbed"
|
||||
);
|
||||
assert_eq!(
|
||||
count_regs(&repo),
|
||||
before,
|
||||
"user registrations outside grok home must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_unparseable_stamp_fails_open() {
|
||||
let _g = env_guard();
|
||||
|
|
|
|||
|
|
@ -445,10 +445,6 @@ fn rehydrate_worktree_from_ref_inner(
|
|||
) -> Result<WorktreeReport> {
|
||||
let dest_str = dest.to_string_lossy();
|
||||
|
||||
// A previously-disposed worktree can leave a stale registration for this
|
||||
// path; prune it so re-adding the original `subagent-<id>` dir succeeds.
|
||||
snapshot_git(source_repo, &["worktree", "prune"], &[])?;
|
||||
|
||||
// The snapshot's first parent is the original base. Resolve it, then confirm
|
||||
// the object is actually present — a parent-repo `git reset --hard` + gc can
|
||||
// leave the parent pointer dangling, which `rev-parse` alone would not catch.
|
||||
|
|
@ -478,11 +474,14 @@ fn rehydrate_worktree_from_ref_inner(
|
|||
// A prior rehydrate may have failed after `worktree add` and left a partial dir; remove it so this attempt starts clean (worktree add fails on an existing path).
|
||||
if dest.exists() {
|
||||
let _ = crate::remove_worktree(dest);
|
||||
let _ = snapshot_git(source_repo, &["worktree", "prune"], &[]);
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
// `git worktree add` refuses a path another registration still claims,
|
||||
// so scrub `dest`'s stale registration (previously-disposed worktree, or
|
||||
// the raw `remove_dir_all` fallback above) before adding.
|
||||
crate::git::remove_stale_worktree_registration(source_repo, dest);
|
||||
snapshot_git(
|
||||
source_repo,
|
||||
&[
|
||||
|
|
@ -509,7 +508,7 @@ fn rehydrate_worktree_from_ref_inner(
|
|||
Err(e) => {
|
||||
// Best-effort cleanup; preserve the original error.
|
||||
let _ = crate::remove_worktree(dest);
|
||||
let _ = snapshot_git(source_repo, &["worktree", "prune"], &[]);
|
||||
crate::git::remove_stale_worktree_registration(source_repo, dest);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
|
@ -1121,6 +1120,50 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Rehydrate must clear its own stale registration (so re-adding the
|
||||
/// same path succeeds) while leaving every other entry alone — its
|
||||
/// cleanup once pruned repo-wide and destroyed user registrations whose
|
||||
/// paths were not visible from the container mount namespace.
|
||||
#[test]
|
||||
fn test_rehydrate_clears_only_its_own_stale_registration() {
|
||||
xai_test_utils::require_git!();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let (repo_path, wt) = repo_with_worktree(&temp);
|
||||
|
||||
std::fs::write(wt.join("tracked.txt"), "edited").unwrap();
|
||||
let snap = snapshot_worktree_to_ref(&wt, "refs/grok/snapshots/stalereg", "stale").unwrap();
|
||||
crate::remove_worktree(&wt).unwrap();
|
||||
|
||||
rehydrate_worktree_from_ref(&wt, &repo_path, &snap, None).unwrap();
|
||||
let registration = repo_path
|
||||
.join(".git")
|
||||
.join("worktrees")
|
||||
.join(wt.file_name().unwrap());
|
||||
assert!(registration.is_dir(), "linked registration expected");
|
||||
|
||||
let user_wt = temp.path().join("user-wt");
|
||||
git_capture_in(
|
||||
&repo_path,
|
||||
&["worktree", "add", "--detach", user_wt.to_str().unwrap()],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::rename(&user_wt, temp.path().join("user-wt-hidden")).unwrap();
|
||||
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
let report = rehydrate_worktree_from_ref(&wt, &repo_path, &snap, None).unwrap();
|
||||
assert_eq!(report.worktree_path, wt);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(wt.join("tracked.txt")).unwrap(),
|
||||
"edited"
|
||||
);
|
||||
assert!(
|
||||
repo_path.join(".git/worktrees/user-wt").exists(),
|
||||
"user registration must survive rehydrate even when its path is hidden"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_snapshot_to_repo_makes_standalone_ref_durable() {
|
||||
xai_test_utils::require_git!();
|
||||
|
|
|
|||
|
|
@ -18,3 +18,7 @@ pub(crate) use discovery::{find_worktree_root, get_head_commit};
|
|||
pub(crate) use index::{copy_git_index, update_index_stats};
|
||||
pub(crate) use status::get_modified_files;
|
||||
pub(crate) use worktree::worktree_add_no_checkout;
|
||||
pub use worktree::{
|
||||
StaleWorktreeMatch, remove_stale_worktree_registration, remove_stale_worktree_registrations,
|
||||
remove_stale_worktree_registrations_under,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Git worktree operations.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
|
|
@ -28,3 +28,372 @@ pub(crate) fn worktree_add_no_checkout(source: &Path, dest: &str, git_ref: &str)
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Which stale registrations [`remove_stale_worktree_registrations`] removes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum StaleWorktreeMatch<'a> {
|
||||
/// Exactly the registration whose recorded worktree path is this path.
|
||||
Path(&'a Path),
|
||||
/// Every registration whose recorded worktree path is under this prefix
|
||||
/// (e.g. a tool-owned base directory, proving ownership of the entries).
|
||||
UnderPrefix(&'a Path),
|
||||
}
|
||||
|
||||
/// Remove stale `.git/worktrees/<id>` registrations matching `match_rule`.
|
||||
///
|
||||
/// Deliberately not `git worktree prune`: prune deletes every registration
|
||||
/// whose worktree path is not visible from the current mount namespace (git
|
||||
/// applies no expiry protection to that case) and deletes `.git/worktrees`
|
||||
/// itself once emptied — under a container that does not mount the user's
|
||||
/// linked worktrees, that wiped them all. Best-effort: failures are logged,
|
||||
/// never returned. Returns the number of registrations removed (git suffixes
|
||||
/// ids on basename collisions, so an id may differ from the basename).
|
||||
pub fn remove_stale_worktree_registrations(
|
||||
source_repo: &Path,
|
||||
match_rule: StaleWorktreeMatch<'_>,
|
||||
) -> u64 {
|
||||
if let StaleWorktreeMatch::Path(p) = match_rule
|
||||
&& p.exists()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
let common_dir = match git_command()
|
||||
.current_dir(source_repo)
|
||||
.args(["rev-parse", "--git-common-dir"])
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => {
|
||||
let path = PathBuf::from(String::from_utf8_lossy(&o.stdout).trim());
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
source_repo.join(path)
|
||||
}
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
stderr = %String::from_utf8_lossy(&o.stderr),
|
||||
"stale registration scrub skipped: git rev-parse --git-common-dir failed"
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source_repo = %source_repo.display(),
|
||||
error = %e,
|
||||
"stale registration scrub skipped: git failed to spawn"
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(common_dir.join("worktrees")) else {
|
||||
return 0;
|
||||
};
|
||||
let normalized_target = match match_rule {
|
||||
StaleWorktreeMatch::Path(p) | StaleWorktreeMatch::UnderPrefix(p) => normalized_for_match(p),
|
||||
};
|
||||
let mut removed = 0u64;
|
||||
for entry in entries.flatten() {
|
||||
let registration = entry.path();
|
||||
if !registration.is_dir() || registration.join("locked").exists() {
|
||||
continue;
|
||||
}
|
||||
let Ok(backlink) = std::fs::read_to_string(registration.join("gitdir")) else {
|
||||
continue;
|
||||
};
|
||||
// The backlink names `<worktree>/.git`; under
|
||||
// `worktree.useRelativePaths` (git >= 2.48) it is relative to the
|
||||
// registration dir, not the CWD.
|
||||
let backlink_path = Path::new(backlink.trim());
|
||||
let backlink_abs = if backlink_path.is_relative() {
|
||||
registration.join(backlink_path)
|
||||
} else {
|
||||
backlink_path.to_path_buf()
|
||||
};
|
||||
let Some(recorded) = backlink_abs.parent() else {
|
||||
continue;
|
||||
};
|
||||
if recorded.exists() {
|
||||
continue;
|
||||
}
|
||||
let recorded = normalized_for_match(recorded);
|
||||
let matched = match match_rule {
|
||||
StaleWorktreeMatch::Path(_) => recorded == normalized_target,
|
||||
StaleWorktreeMatch::UnderPrefix(_) => recorded.starts_with(&normalized_target),
|
||||
};
|
||||
if !matched {
|
||||
continue;
|
||||
}
|
||||
match std::fs::remove_dir_all(®istration) {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
registration = %registration.display(),
|
||||
worktree = %recorded.display(),
|
||||
"removed stale worktree registration"
|
||||
);
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
registration = %registration.display(),
|
||||
error = %e,
|
||||
"failed to remove stale worktree registration"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// [`remove_stale_worktree_registrations`] scoped to exactly one worktree path.
|
||||
pub fn remove_stale_worktree_registration(source_repo: &Path, worktree_path: &Path) -> u64 {
|
||||
remove_stale_worktree_registrations(source_repo, StaleWorktreeMatch::Path(worktree_path))
|
||||
}
|
||||
|
||||
/// [`remove_stale_worktree_registrations`] scoped to a tool-owned base directory.
|
||||
pub fn remove_stale_worktree_registrations_under(source_repo: &Path, prefix: &Path) -> u64 {
|
||||
remove_stale_worktree_registrations(source_repo, StaleWorktreeMatch::UnderPrefix(prefix))
|
||||
}
|
||||
|
||||
/// Canonicalize the deepest existing ancestor and re-append the missing
|
||||
/// tail: git records the realpath at `worktree add` time, so a symlinked
|
||||
/// spelling must compare equal even after the path itself is deleted.
|
||||
fn normalized_for_match(path: &Path) -> PathBuf {
|
||||
let mut missing = Vec::new();
|
||||
let mut cursor = path;
|
||||
loop {
|
||||
if let Ok(canonical) = dunce::canonicalize(cursor) {
|
||||
let mut result = canonical;
|
||||
for component in missing.iter().rev() {
|
||||
result.push(component);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
match (cursor.parent(), cursor.file_name()) {
|
||||
(Some(parent), Some(name)) => {
|
||||
missing.push(name.to_os_string());
|
||||
cursor = parent;
|
||||
}
|
||||
_ => return path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run_git(cwd: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("run git");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn init_repo_with_worktrees() -> (tempfile::TempDir, PathBuf) {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
run_git(&repo, &["init"]);
|
||||
run_git(&repo, &["config", "user.email", "t@test"]);
|
||||
run_git(&repo, &["config", "user.name", "t"]);
|
||||
std::fs::write(repo.join("f.txt"), b"x").unwrap();
|
||||
run_git(&repo, &["add", "f.txt"]);
|
||||
run_git(&repo, &["commit", "-m", "init"]);
|
||||
(tmp, repo)
|
||||
}
|
||||
|
||||
fn add_worktree(repo: &Path, wt: &Path) {
|
||||
run_git(
|
||||
repo,
|
||||
&["worktree", "add", "--detach", wt.to_str().unwrap(), "HEAD"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_only_the_matching_stale_registration() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let git_worktrees = repo.join(".git").join("worktrees");
|
||||
|
||||
let target = tmp.path().join("target-wt");
|
||||
add_worktree(&repo, &target);
|
||||
std::fs::remove_dir_all(&target).unwrap();
|
||||
|
||||
let hidden = tmp.path().join("hidden-wt");
|
||||
add_worktree(&repo, &hidden);
|
||||
std::fs::rename(&hidden, tmp.path().join("hidden-wt-moved")).unwrap();
|
||||
|
||||
std::fs::create_dir(git_worktrees.join("bare-entry")).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registration(&repo, &target);
|
||||
|
||||
assert_eq!(removed, 1);
|
||||
assert!(!git_worktrees.join("target-wt").exists());
|
||||
assert!(
|
||||
git_worktrees.join("hidden-wt").exists(),
|
||||
"non-matching registration must survive even when its path is gone"
|
||||
);
|
||||
assert!(git_worktrees.join("bare-entry").exists());
|
||||
assert!(git_worktrees.exists());
|
||||
}
|
||||
|
||||
/// Rewrite a registration's `gitdir` backlink to the relative layout
|
||||
/// `worktree.useRelativePaths` (git >= 2.48) produces, without requiring
|
||||
/// that git version on the test host.
|
||||
fn make_backlink_relative(repo: &Path, reg_name: &str, worktree: &Path) {
|
||||
let reg_dir = repo.join(".git").join("worktrees").join(reg_name);
|
||||
let target = worktree.join(".git");
|
||||
let mut ups = PathBuf::new();
|
||||
let mut cursor = reg_dir.as_path();
|
||||
loop {
|
||||
if let Ok(rest) = target.strip_prefix(cursor) {
|
||||
std::fs::write(
|
||||
reg_dir.join("gitdir"),
|
||||
format!("{}\n", ups.join(rest).display()),
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
cursor = cursor.parent().expect("shared ancestor");
|
||||
ups.push("..");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_backlink_against_registration_dir() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
|
||||
let stale = tmp.path().join("rel-stale");
|
||||
add_worktree(&repo, &stale);
|
||||
make_backlink_relative(&repo, "rel-stale", &stale);
|
||||
std::fs::remove_dir_all(&stale).unwrap();
|
||||
|
||||
let live = tmp.path().join("rel-live");
|
||||
add_worktree(&repo, &live);
|
||||
make_backlink_relative(&repo, "rel-live", &live);
|
||||
|
||||
let removed_live = remove_stale_worktree_registrations_under(&repo, tmp.path());
|
||||
assert_eq!(removed_live, 1, "only the stale relative entry is removed");
|
||||
assert!(!repo.join(".git/worktrees/rel-stale").exists());
|
||||
assert!(
|
||||
repo.join(".git/worktrees/rel-live").exists(),
|
||||
"live worktree with relative backlink must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_registration_when_worktree_still_exists() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let wt = tmp.path().join("live-wt");
|
||||
add_worktree(&repo, &wt);
|
||||
|
||||
let removed = remove_stale_worktree_registration(&repo, &wt);
|
||||
|
||||
assert_eq!(removed, 0);
|
||||
assert!(repo.join(".git/worktrees/live-wt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_locked_registration() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let wt = tmp.path().join("locked-wt");
|
||||
add_worktree(&repo, &wt);
|
||||
run_git(&repo, &["worktree", "lock", wt.to_str().unwrap()]);
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registration(&repo, &wt);
|
||||
|
||||
assert_eq!(removed, 0);
|
||||
assert!(repo.join(".git/worktrees/locked-wt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_prefix_removes_only_owned_stale_registrations() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let git_worktrees = repo.join(".git").join("worktrees");
|
||||
|
||||
let owned_base = tmp.path().join("owned-base");
|
||||
let owned_stale = owned_base.join("instance").join("wt-stale");
|
||||
let owned_locked = owned_base.join("instance").join("wt-locked");
|
||||
let owned_live = owned_base.join("instance").join("wt-live");
|
||||
std::fs::create_dir_all(owned_stale.parent().unwrap()).unwrap();
|
||||
add_worktree(&repo, &owned_stale);
|
||||
add_worktree(&repo, &owned_locked);
|
||||
add_worktree(&repo, &owned_live);
|
||||
std::fs::remove_dir_all(&owned_stale).unwrap();
|
||||
run_git(&repo, &["worktree", "lock", owned_locked.to_str().unwrap()]);
|
||||
std::fs::remove_dir_all(&owned_locked).unwrap();
|
||||
|
||||
let foreign = tmp.path().join("foreign-wt");
|
||||
add_worktree(&repo, &foreign);
|
||||
std::fs::rename(&foreign, tmp.path().join("foreign-wt-moved")).unwrap();
|
||||
|
||||
std::fs::create_dir(git_worktrees.join("bare-entry")).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registrations_under(&repo, &owned_base);
|
||||
|
||||
assert_eq!(removed, 1);
|
||||
assert!(!git_worktrees.join("wt-stale").exists());
|
||||
assert!(
|
||||
git_worktrees.join("wt-locked").exists(),
|
||||
"locked registration must survive even when owned and stale"
|
||||
);
|
||||
assert!(
|
||||
git_worktrees.join("wt-live").exists(),
|
||||
"live owned registration must survive"
|
||||
);
|
||||
assert!(
|
||||
git_worktrees.join("foreign-wt").exists(),
|
||||
"foreign registration must survive even when its path is hidden"
|
||||
);
|
||||
assert!(git_worktrees.join("bare-entry").exists());
|
||||
assert!(git_worktrees.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn under_prefix_matches_symlinked_base_spelling() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
|
||||
let real_base = tmp.path().join("real-base");
|
||||
std::fs::create_dir(&real_base).unwrap();
|
||||
let wt = real_base.join("wt");
|
||||
add_worktree(&repo, &wt);
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
let alias_base = tmp.path().join("alias-base");
|
||||
std::os::unix::fs::symlink(&real_base, &alias_base).unwrap();
|
||||
|
||||
let removed = remove_stale_worktree_registrations_under(&repo, &alias_base);
|
||||
assert_eq!(removed, 1, "symlinked base spelling must match");
|
||||
assert!(!repo.join(".git/worktrees/wt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_across_symlinked_parent_spelling() {
|
||||
let (tmp, repo) = init_repo_with_worktrees();
|
||||
let real_parent = tmp.path().join("real-parent");
|
||||
std::fs::create_dir(&real_parent).unwrap();
|
||||
let wt = real_parent.join("wt");
|
||||
add_worktree(&repo, &wt);
|
||||
std::fs::remove_dir_all(&wt).unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let alias = tmp.path().join("alias-parent");
|
||||
std::os::unix::fs::symlink(&real_parent, &alias).unwrap();
|
||||
let removed = remove_stale_worktree_registration(&repo, &alias.join("wt"));
|
||||
assert_eq!(removed, 1, "symlinked spelling of the parent must match");
|
||||
assert!(!repo.join(".git/worktrees/wt").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ pub use discovery::{RebuildReport, discover_worktrees, rebuild_worktree_db};
|
|||
pub use git::checkout::{
|
||||
rehydrate_worktree_from_ref, snapshot_worktree_to_ref, transfer_snapshot_to_repo,
|
||||
};
|
||||
pub use git::{
|
||||
StaleWorktreeMatch, remove_stale_worktree_registration, remove_stale_worktree_registrations,
|
||||
remove_stale_worktree_registrations_under,
|
||||
};
|
||||
pub use sync::{SourceDirtyState, SyncReport, WorktreeSync, collect_source_dirty_state};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use worktree::execute::cleanup_snapshot_git_state;
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ fn replay_staged_changes(
|
|||
// <mode> SP <hex-hash> TAB <path> NUL
|
||||
if !staged_adds.is_empty() {
|
||||
use std::io::Write;
|
||||
#[allow(clippy::disallowed_methods)] // git command, waited on below
|
||||
let mut child = git_command()
|
||||
.current_dir(worktree)
|
||||
.args(["update-index", "-z", "--index-info"])
|
||||
|
|
|
|||
Loading…
Reference in a new issue