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
2 changes: 2 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[alias]
dev-install = "run -p xtask --release -q -- install"
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ src/
└── mod.rs # TOML parsing, XDG paths, default keybindings
```

The repo is a Cargo workspace. The only non-root member is `xtask/` — dev tooling (`publish = false`, `dist = false`, never shipped in releases) that provides the `cargo dev-install` / `cargo run -p xtask -- uninstall` commands.

## Core Concepts

### File List (zero-config)
Expand Down Expand Up @@ -149,6 +151,7 @@ cargo test # Run tests
cargo clippy # Lint
cargo fmt # Format
RUST_LOG=debug cargo run # Run with debug logging
cargo dev-install # symlink the debug build into ~/.local/bin (xtask crate)
```

## Current Status
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ keywords = ["git", "tui", "terminal", "dashboard", "developer-tools"]
categories = ["command-line-utilities", "development-tools"]
default-run = "perch"

[workspace]
members = ["xtask"]
resolver = "2"

[[bin]]
name = "perch"
path = "src/main.rs"
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ cargo clippy # lint
cargo fmt # format
```

### Dev install (run your local build as `perch`)

```bash
cargo dev-install # build debug + symlink into ~/.local/bin
cargo run -p xtask -- uninstall # remove the symlink
```

This symlinks `target/debug/perch` into `~/.local/bin/perch`, so every later
`cargo build` is instantly live. It does **not** touch a Homebrew-installed
`perch` (that lives in the brew prefix); which one runs depends on PATH order —
the command warns if `~/.local/bin` is missing from PATH or shadowed.

### Nix commands

```bash
Expand Down
66 changes: 66 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//! Build script that captures git describe information for the version string.
//!
//! Emits the `PERCH_GIT_DESCRIBE` env var consumed by `src/main.rs`:
//! - `<7-char-sha>` on a clean tree
//! - `<7-char-sha>.dirty` when the index or working tree has uncommitted changes
//! - `unknown` when git is unavailable (e.g. building from a source tarball)
//!
//! # Staleness caveat
//! Cargo re-runs this script only when the listed `rerun-if-changed` paths
//! change. We watch `.git/HEAD` (catches commits and branch switches) and
//! `.git/index` (catches staged changes, which also cover most `git add`
//! operations). A pure working-tree edit that has *not* been staged will not
//! trigger a rebuild, so the `.dirty` flag may lag until the next `git add` or
//! any other event that touches `.git/index`.

use std::process::Command;

fn main() {
// Tell Cargo to re-run this script when HEAD changes (commits, checkouts)
// or when the index changes (staging). Cargo tolerates missing paths by
// treating them as always-changed, so these are safe to emit unconditionally
// (e.g. when building from a tarball without a .git directory).
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/index");

let describe = git_describe().unwrap_or_else(|| "unknown".to_owned());
println!("cargo:rustc-env=PERCH_GIT_DESCRIBE={describe}");
}

/// Returns `Some("<sha>")` or `Some("<sha>.dirty")` when git is available,
/// `None` when any git invocation fails.
fn git_describe() -> Option<String> {
let sha = short_sha()?;
let dirty = is_dirty()?;
if dirty {
Some(format!("{sha}.dirty"))
} else {
Some(sha)
}
}

/// Runs `git rev-parse --short=7 HEAD` and returns the trimmed output.
fn short_sha() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short=7", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8(output.stdout).ok()?;
Some(sha.trim().to_owned())
}

/// Runs `git status --porcelain` and returns `true` when the output is
/// non-empty (i.e. there are staged or unstaged changes).
fn is_dirty() -> Option<bool> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(!output.stdout.is_empty())
}
31 changes: 30 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,23 @@ use anyhow::{Context, Result};
use clap::Parser;
use tracing_subscriber::EnvFilter;

/// Version reported by `--version`. Debug builds append the git describe
/// string (`-dev.<sha>[.dirty]`); release builds report the clean crate
/// version. `PERCH_GIT_DESCRIBE` is set by build.rs.
const VERSION: &str = if cfg!(debug_assertions) {
concat!(
env!("CARGO_PKG_VERSION"),
"-dev.",
env!("PERCH_GIT_DESCRIBE")
)
} else {
env!("CARGO_PKG_VERSION")
};

#[derive(Parser, Debug)]
#[command(
name = "perch",
version,
version = VERSION,
about = "Real-time terminal dashboard for git changes"
)]
struct Cli {
Expand Down Expand Up @@ -102,3 +115,19 @@ fn main() -> Result<()> {
);
app.run()
}

#[cfg(test)]
mod tests {
use super::VERSION;

#[test]
fn version_has_dev_suffix_in_debug_builds() {
// `cargo test` compiles with debug_assertions enabled, so VERSION must
// carry the dev describe suffix and still start with the crate version.
assert!(
VERSION.starts_with(env!("CARGO_PKG_VERSION")),
"got {VERSION}"
);
assert!(VERSION.contains("-dev."), "got {VERSION}");
}
}
14 changes: 14 additions & 0 deletions xtask/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "xtask"
version = "0.0.0"
edition = "2021"
publish = false

[package.metadata.dist]
dist = false

[dependencies]
anyhow = "1"

[dev-dependencies]
tempfile = "3"
128 changes: 128 additions & 0 deletions xtask/src/install.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Filesystem helpers for the dev-install xtask.

use std::fs;
use std::path::Path;

use anyhow::{bail, Context, Result};

/// Symlink each named binary from `target_dir` into `bin_dir`.
///
/// Replaces an existing symlink at the destination, but refuses to clobber a
/// real (non-symlink) file. Creates `bin_dir` if it does not exist.
pub fn link_binaries(bin_dir: &Path, target_dir: &Path, names: &[&str]) -> Result<()> {
fs::create_dir_all(bin_dir)
.with_context(|| format!("creating bin dir {}", bin_dir.display()))?;

for name in names {
let src = target_dir.join(name);
let dest = bin_dir.join(name);

match dest.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => {
fs::remove_file(&dest)
.with_context(|| format!("removing existing symlink {}", dest.display()))?;
}
Ok(_) => bail!(
"refusing to overwrite real file {} (not a symlink)",
dest.display()
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("inspecting {}", dest.display())),
}

std::os::unix::fs::symlink(&src, &dest)
.with_context(|| format!("symlinking {} -> {}", dest.display(), src.display()))?;
}

Ok(())
}

/// Remove `name` from `bin_dir` only if it is a symlink. Returns whether
/// anything was removed. Refuses to delete a real (non-symlink) file.
pub fn unlink_binary(bin_dir: &Path, name: &str) -> Result<bool> {
let dest = bin_dir.join(name);
match dest.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => {
fs::remove_file(&dest)
.with_context(|| format!("removing symlink {}", dest.display()))?;
Ok(true)
}
Ok(_) => bail!("{} is not a symlink; leaving it untouched", dest.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e).with_context(|| format!("inspecting {}", dest.display())),
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;

#[test]
fn links_fresh_binary() {
let bin = tempdir().unwrap();
let target = tempdir().unwrap();
fs::write(target.path().join("perch"), b"binary").unwrap();

link_binaries(bin.path(), target.path(), &["perch"]).unwrap();

let link = bin.path().join("perch");
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(fs::read_link(&link).unwrap(), target.path().join("perch"));
}

#[test]
fn relink_replaces_existing_symlink() {
let bin = tempdir().unwrap();
let target = tempdir().unwrap();
fs::write(target.path().join("perch"), b"binary").unwrap();

link_binaries(bin.path(), target.path(), &["perch"]).unwrap();
// second call must not error
link_binaries(bin.path(), target.path(), &["perch"]).unwrap();

let link = bin.path().join("perch");
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
}

#[test]
fn refuses_to_clobber_real_file() {
let bin = tempdir().unwrap();
let target = tempdir().unwrap();
let real = bin.path().join("perch");
fs::write(&real, b"do not delete me").unwrap();

let err = link_binaries(bin.path(), target.path(), &["perch"]).unwrap_err();
assert!(err.to_string().contains("refusing to overwrite"));
assert_eq!(fs::read(&real).unwrap(), b"do not delete me");
}

#[test]
fn unlink_removes_symlink() {
let bin = tempdir().unwrap();
let target = tempdir().unwrap();
fs::write(target.path().join("perch"), b"binary").unwrap();
link_binaries(bin.path(), target.path(), &["perch"]).unwrap();

assert!(unlink_binary(bin.path(), "perch").unwrap());
assert!(bin.path().join("perch").symlink_metadata().is_err());
}

#[test]
fn unlink_missing_is_noop() {
let bin = tempdir().unwrap();
assert!(!unlink_binary(bin.path(), "perch").unwrap());
}

#[test]
fn unlink_refuses_real_file() {
let bin = tempdir().unwrap();
let real = bin.path().join("perch");
fs::write(&real, b"keep me").unwrap();

let err = unlink_binary(bin.path(), "perch").unwrap_err();
assert!(err.to_string().contains("not a symlink"));
assert!(real.exists());
}
}
Loading
Loading