Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/session-worktree-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tracedecay": patch
---

Bound session worktree discovery so Git timeouts remain retryable without re-entering expensive repository scans or advancing transcript cursors.
16 changes: 9 additions & 7 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1227,15 +1227,17 @@ async fn resolve_daemon_initialize_route(
break;
}
}
if let Some(git_root) = crate::worktree::git_worktree_root(root) {
let git_common_dir = crate::worktree::git_common_dir(&git_root);
if let Some(identity) = crate::worktree::git_repo_identity(root) {
if registry
.project_registry_context_by_identity(&git_root, git_common_dir.as_deref())
.project_registry_context_by_identity(
&identity.worktree_root,
Some(&identity.common_dir),
)
.await
.is_some()
{
return Some(InitializeRouteMetadata {
project_path: git_root,
project_path: identity.worktree_root,
allow_init: false,
});
}
Expand All @@ -1258,10 +1260,10 @@ async fn resolve_daemon_initialize_route(
allow_init: false,
});
}
if let Some(git_root) = crate::worktree::git_worktree_root(&root) {
let allow_init = crate::config::load_sync_config(&git_root).auto_init;
if let Some(identity) = crate::worktree::git_repo_identity(&root) {
let allow_init = crate::config::load_sync_config(&identity.worktree_root).auto_init;
return Some(InitializeRouteMetadata {
project_path: git_root,
project_path: identity.worktree_root,
allow_init,
});
}
Expand Down
52 changes: 46 additions & 6 deletions src/daemon/git_watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,14 +466,54 @@ async fn supervise_project(inner: Arc<GitWatcherInner>, state: Arc<WatchState>)
/// One project's event loop: build the notify watcher over git metadata, then
/// debounce raw events into coalesced syncs. On watcher construction/death,
/// fall back to a 5-minute mtime poll for THIS project only.
enum IdentityDiscoveryDisposition {
Watch(crate::worktree::GitRepoIdentity),
Degraded,
Retry,
}

fn identity_discovery_disposition(
outcome: crate::worktree::GitRepoIdentityOutcome,
) -> IdentityDiscoveryDisposition {
match outcome {
crate::worktree::GitRepoIdentityOutcome::Resolved(identity) => {
IdentityDiscoveryDisposition::Watch(identity)
}
crate::worktree::GitRepoIdentityOutcome::NotFound => IdentityDiscoveryDisposition::Degraded,
crate::worktree::GitRepoIdentityOutcome::Unknown => IdentityDiscoveryDisposition::Retry,
}
}

async fn project_task(inner: Arc<GitWatcherInner>, state: Arc<WatchState>) {
let Some(common_dir) = crate::worktree::git_common_dir(&state.project_root) else {
// Not a resolvable git repo (yet). Degrade to polling so a later `git
// init` / clone is still eventually covered.
state.health.set_degraded(true);
degraded_poll_loop(&inner, &state, None).await;
return;
let mut discovery_backoff = Duration::from_millis(500);
let identity = loop {
match identity_discovery_disposition(crate::worktree::git_repo_identity_outcome(
&state.project_root,
)) {
IdentityDiscoveryDisposition::Watch(identity) => break identity,
IdentityDiscoveryDisposition::Degraded => {
// Definitively not a git repo (yet). Degrade to polling so a
// later `git init` / clone is still eventually covered.
state.health.set_degraded(true);
degraded_poll_loop(&inner, &state, None).await;
return;
}
IdentityDiscoveryDisposition::Retry => {
state.health.set_degraded(true);
state.health.beat();
log_daemon_event(
"git_watch_discovery_retry",
&[
("project", state.project_root.display().to_string()),
("backoff_ms", discovery_backoff.as_millis().to_string()),
],
);
tokio::time::sleep(discovery_backoff).await;
discovery_backoff = (discovery_backoff * 2).min(RESTART_BACKOFF_MAX);
}
}
};
let common_dir = identity.common_dir;

// Build the raw watcher. Its callback pushes into the dirty set and wakes
// the debounce loop — it never blocks and never syncs inline.
Expand Down
12 changes: 12 additions & 0 deletions src/daemon/git_watch/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ fn heartbeat_staleness() {
assert!(old.heartbeat_stale());
}

#[test]
fn timed_out_identity_discovery_retries_instead_of_degrading_forever() {
assert!(matches!(
identity_discovery_disposition(crate::worktree::GitRepoIdentityOutcome::Unknown),
IdentityDiscoveryDisposition::Retry
));
assert!(matches!(
identity_discovery_disposition(crate::worktree::GitRepoIdentityOutcome::NotFound),
IdentityDiscoveryDisposition::Degraded
));
}

/// The shared coordinator must not start a second store-writing lifetime while
/// the first one is held. Paused Tokio time plus Notify/oneshot handshakes make
/// this a scheduling-state assertion rather than a wall-clock sleep.
Expand Down
145 changes: 140 additions & 5 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@
//! absolute path exactly once (cached in a [`OnceLock`]) and hands every product
//! spawn site that cached path, so the long-running daemon never re-walks `PATH`.
//!
//! The gix-first read paths in [`crate::branch`] and [`crate::worktree`] are
//! unaffected: they still prefer in-process `gix` and only reach a `git`
//! subprocess as a gated fallback. This module only changes *which* program those
//! fallbacks (and the one-shot spawn sites) exec.
//! The public gix-first read paths in [`crate::branch`] and [`crate::worktree`]
//! are unaffected: they still prefer in-process `gix` and only reach a `git`
//! subprocess as a gated fallback.

use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::process::{Child, Command, Output, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};

/// The literal used when resolution fails, preserving today's behavior (the OS
/// PATH-walks per spawn, but callers keep working).
const GIT_LITERAL: &str = "git";
const GIT_CAPTURE_AT_TIMEOUT: Duration = Duration::from_secs(2);
const CHILD_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(10);

/// Returns the resolved `git` program to spawn, as a cached `&'static OsStr`.
///
Expand Down Expand Up @@ -129,6 +131,92 @@ pub(crate) fn git_capture(repo_root: &Path, args: &[&str]) -> Option<String> {
(!trimmed.is_empty()).then(|| trimmed.to_string())
}

/// Outcome of the bounded `git -C` capture used by repository identity lookup.
#[derive(Debug)]
pub(crate) enum GitCaptureAtResult {
Captured(String),
Failed,
TimedOut,
}

/// Runs `git -C <repo_root> <args>` without setting the child process working
/// directory to `repo_root`.
///
/// Some network-backed or otherwise unhealthy project roots can block inside
/// the child's initial `getcwd` when passed through [`Command::current_dir`].
/// Git's `-C` resolves the repository after process startup and avoids that
/// pre-argument cwd lookup. The child is killed and reaped at the hard deadline.
pub(crate) fn git_capture_at(repo_root: &Path, args: &[&str]) -> GitCaptureAtResult {
let mut command = git_command_at(repo_root, args);
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let Ok(child) = command.spawn() else {
return GitCaptureAtResult::Failed;
};
match capture_child_with_deadline(child, GIT_CAPTURE_AT_TIMEOUT) {
ChildCaptureResult::Completed(output) if output.status.success() => {
let Ok(text) = String::from_utf8(output.stdout) else {
return GitCaptureAtResult::Failed;
};
let trimmed = text.trim();
if trimmed.is_empty() {
GitCaptureAtResult::Failed
} else {
GitCaptureAtResult::Captured(trimmed.to_string())
}
}
ChildCaptureResult::TimedOut => GitCaptureAtResult::TimedOut,
ChildCaptureResult::Completed(_) | ChildCaptureResult::Failed => GitCaptureAtResult::Failed,
}
}

fn git_command_at(repo_root: &Path, args: &[&str]) -> Command {
let mut command = Command::new(git_program());
command.arg("-C").arg(repo_root).args(args);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear repository overrides before using -C

When TraceDecay is started from a Git hook or another process that exports repository-local variables, GIT_DIR, GIT_WORK_TREE, or GIT_COMMON_DIR overrides the repository selected by -C (git rev-parse --local-env-vars lists these variables). The new CLI-first resolver can therefore report the inherited repository's common directory for every supplied root, causing unrelated transcript paths and daemon initialize roots to be treated as the same project; clear these repository-selection variables on this command while preserving the supported GIT executable override.

Useful? React with 👍 / 👎.

command
}

#[derive(Debug)]
enum ChildCaptureResult {
Completed(Output),
Failed,
TimedOut,
}

fn capture_child_with_deadline(mut child: Child, timeout: Duration) -> ChildCaptureResult {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map(ChildCaptureResult::Completed)
.unwrap_or(ChildCaptureResult::Failed);
}
Ok(None) => {}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return ChildCaptureResult::Failed;
}
}

let now = Instant::now();
if now >= deadline {
let _ = child.kill();
return if child.wait().is_ok() {
ChildCaptureResult::TimedOut
} else {
ChildCaptureResult::Failed
};
}
std::thread::sleep(
deadline
.saturating_duration_since(now)
.min(CHILD_WAIT_POLL_INTERVAL),
);
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
Expand All @@ -151,6 +239,53 @@ mod tests {
);
}

#[test]
fn git_at_command_uses_dash_c_without_target_current_dir() {
let repo_root = Path::new("/problematic/project/root");
let command = git_command_at(
repo_root,
&["rev-parse", "--show-toplevel", "--git-common-dir"],
);

assert!(
command.get_current_dir().is_none(),
"git -C must inherit the safe daemon cwd instead of entering the target root"
);
assert_eq!(
command
.get_args()
.map(std::ffi::OsStr::to_os_string)
.collect::<Vec<_>>(),
vec![
OsString::from("-C"),
repo_root.as_os_str().to_os_string(),
OsString::from("rev-parse"),
OsString::from("--show-toplevel"),
OsString::from("--git-common-dir"),
]
);
}

#[cfg(unix)]
#[test]
fn git_capture_deadline_kills_and_reaps_child() {
let child = Command::new("/bin/sleep")
.arg("30")
.spawn()
.expect("spawn sleeping child");
let started = std::time::Instant::now();

let result = capture_child_with_deadline(child, std::time::Duration::from_millis(25));

let ChildCaptureResult::TimedOut = result else {
panic!("sleeping child should time out, got {result:?}");
};
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"deadline must stop and reap the child promptly"
);
}

#[test]
fn git_env_override_is_honored() {
// resolve_git_program() reads GIT directly; test it in isolation so the
Expand Down
Loading