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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `--commit` flag to restart the command when git HEAD changes. (#36)

### Fixed

- `is_hidden_path` no longer filters out paths under explicitly-watched hidden
directories, fixing an issue where `--commit` could not detect git changes when
the watch path was a parent of `.git`. (#36)

## [0.3.5] - 2026-06-02

### Changed
Expand Down
175 changes: 158 additions & 17 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,56 @@ pub fn xtask_command() -> Command {
Command::new(env::args_os().next().unwrap())
}

/// Resolve the actual git directory path via `git rev-parse --git-dir`.
///
/// Git handles regular repos, worktrees, and submodules transparently.
fn resolve_git_dir() -> Result<PathBuf> {
let repo_root = metadata().workspace_root.as_std_path();
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(repo_root)
.output()
.context("failed to run `git rev-parse --git-dir`")?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("not a git repository: {stderr}");
}

let dir = String::from_utf8(output.stdout).context("git output is not valid UTF-8")?;
let dir = dir.trim();

let path = if Path::new(dir).is_absolute() {
PathBuf::from(dir)
} else {
repo_root.join(dir)
};

path.canonicalize()
.with_context(|| format!("canonicalize git dir `{dir}`"))
}

/// Get the current HEAD commit hash via `git rev-parse HEAD`.
fn get_current_head() -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(metadata().workspace_root.as_std_path())
.output()
.context("failed to run `git rev-parse HEAD`")?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git rev-parse HEAD failed: {stderr}");
}

let hash = String::from_utf8(output.stdout)
.context("git output is not valid UTF-8")?
.trim()
.to_string();

Ok(hash)
}

/// Watches over your project's source code, relaunching a given command when
/// changes are detected.
///
Expand Down Expand Up @@ -71,6 +121,14 @@ pub struct Watch {
/// Paths or glob patterns, relative to the workspace root, that will be excluded.
#[clap(skip)]
pub workspace_exclude_paths: Vec<PathBuf>,
/// Watch for commit changes in addition to file changes.
///
/// Monitors the git directory (resolved via `git rev-parse --git-dir`)
/// for when the current git commit (HEAD) changes.
/// For worktrees, the watched directory is the git directory resolved
/// via `git rev-parse --git-dir`, not the workspace-local `.git` file.
#[clap(long = "commit")]
pub commit: bool,
/// Quiet period after the last detected change before the command is
/// (re)started. If another change arrives while a build is running the
/// build is cancelled and the timer resets, so only the latest state is
Expand Down Expand Up @@ -155,6 +213,12 @@ impl Watch {
self.watch_lock.clone()
}

/// Enable commit mode: also restart the command when the git HEAD changes.
pub fn commit(mut self) -> Self {
self.commit = true;
self
}

/// Set the debounce quiet period.
///
/// The command will not start (or restart) until no change has been
Expand Down Expand Up @@ -204,6 +268,14 @@ impl Watch {

self.prepare_excludes()?;

let git_dirs: Vec<PathBuf> = if self.commit {
let git_dir = resolve_git_dir().context("--commit requires a git repository")?;
self.watch_paths.push(git_dir.clone());
vec![git_dir]
} else {
Vec::new()
};

if self.watch_paths.is_empty() {
self.watch_paths
.push(metadata.workspace_root.clone().into_std_path_buf());
Expand All @@ -223,6 +295,7 @@ impl Watch {
let handler = WatchEventHandler {
watch: self.clone(),
tx: tx.clone(),
git_dirs,
};

let mut watcher =
Expand All @@ -243,10 +316,17 @@ impl Watch {
// been translated into a spawned command. It starts as `true` so the
// first build fires immediately without waiting for a file-change event.
let mut pending_build = true;
let mut current_commit = if self.commit {
Some(get_current_head()?)
} else {
None
};
let mut has_file_changes = false;

loop {
if pending_build {
pending_build = false;
has_file_changes = false;
log::info!("Running command");
let mut current_child = current_child.clone();
let mut list = list.clone();
Expand Down Expand Up @@ -285,19 +365,21 @@ impl Watch {
// been quiet for `debounce`.
loop {
match rx.recv_timeout(self.debounce) {
Ok(Event::ChangeDetected) => {
log::trace!("Change detected, resetting debounce timer");
Ok(Event::ChangeDetected { git }) => {
if git {
log::trace!("Git directory change detected, resetting debounce timer");
} else {
log::trace!("Change detected, resetting debounce timer");
has_file_changes = true;
}
if !pending_build {
// Cancel any in-progress build immediately so we
// build the latest version, not an intermediate one.
current_child.terminate();
generation += 1;
if lock_guard.is_none() {
lock_guard = Some(self.watch_lock.write());
}
pending_build = true;
}
// Loop back to reset the recv_timeout.
}
Ok(Event::CommandSucceeded(build_id)) if build_id == generation => {
log::trace!("Command succeeded, releasing lock");
Expand All @@ -313,6 +395,29 @@ impl Watch {
// Quiet for `debounce` — time to build if there is a
// pending change.
if pending_build {
if !has_file_changes {
if let Some(current_hash) = &current_commit {
match get_current_head() {
Ok(hash) if &hash != current_hash => {
log::trace!(
"HEAD changed: {:?} -> {hash}",
current_commit
);
current_commit = Some(hash);
}
Ok(_) => {
log::trace!("HEAD unchanged, skipping build");
pending_build = false;
continue;
}
Err(err) => {
log::error!("failed to read git HEAD: {err}");
pending_build = false;
continue;
}
}
}
}
break;
}
}
Expand Down Expand Up @@ -356,6 +461,18 @@ impl Watch {
}

fn is_hidden_path(&self, path: &Path) -> bool {
// If the path is under a watch path whose last component starts
// with '.', the user explicitly opted into watching it — don't
// treat it as hidden (e.g. --commit adds .git/ to watch_paths).
if self.watch_paths.iter().any(|x| {
x.file_name()
.and_then(|s| s.to_str())
.is_some_and(|s| s.starts_with('.'))
&& path.starts_with(x)
}) {
return false;
}

self.watch_paths.iter().any(|x| {
path.strip_prefix(x)
.iter()
Expand All @@ -371,6 +488,13 @@ impl Watch {
})
}

fn is_valid_path(&self, path: &Path) -> bool {
path.exists()
&& !self.is_excluded_path(path)
&& !self.is_hidden_path(path)
&& !self.is_backup_file(path)
}

fn is_glob_pattern(path: &Path) -> bool {
let s = path.as_os_str().to_string_lossy();
s.contains('*') || s.contains('?') || (!cfg!(windows) && s.contains('['))
Expand Down Expand Up @@ -440,28 +564,45 @@ impl Watch {
struct WatchEventHandler {
watch: Watch,
tx: mpsc::Sender<Event>,
git_dirs: Vec<PathBuf>,
}

impl notify::EventHandler for WatchEventHandler {
fn handle_event(&mut self, event: Result<notify::Event, notify::Error>) {
match event {
Ok(event) => {
if (event.kind.is_modify() || event.kind.is_create())
&& event.paths.iter().any(|x| {
!self.watch.is_excluded_path(x)
&& x.exists()
&& !self.watch.is_hidden_path(x)
&& !self.watch.is_backup_file(x)
})
{
if event.kind.is_modify() || event.kind.is_create() {
let mut valid_paths = event
.paths
.iter()
.filter(|p| self.watch.is_valid_path(p))
.peekable();

if valid_paths.peek().is_none() {
log::trace!("No valid paths in {event:?}, ignoring");
return;
}

if self.watch.commit
&& valid_paths.all(|p| self.git_dirs.iter().any(|g| p.starts_with(g)))
{
log::trace!("Git directory change detected in {event:?}");
self.tx
.send(Event::ChangeDetected { git: true })
.expect("can send");
return;
}

log::trace!("Changes detected in {event:?}");
self.tx.send(Event::ChangeDetected).expect("can send");
self.tx
.send(Event::ChangeDetected { git: false })
.expect("can send");
} else {
log::trace!("Ignoring changes in {event:?}");
log::trace!("Ignoring non-create/modify event: {event:?}");
}
}
Err(err) => log::error!("watch error: {err}"),
}
};
}
}

Expand Down Expand Up @@ -657,7 +798,7 @@ impl WatchLock {
#[derive(Debug)]
enum Event {
CommandSucceeded(u64),
ChangeDetected,
ChangeDetected { git: bool },
}

#[cfg(test)]
Expand Down
Loading