Appearance
Thank you for your interest in contributing to daft!
Development Setup
Clone the repository using daft's worktree layout:
bashdaft clone git@github.com:avihut/daft.gitBuild and set up the development environment:
bashmise run devThis builds the binary, creates all symlinks in
target/release/, and verifies the setup.Add the binary to your PATH:
bashexport PATH="$PWD/target/release:$PATH"
Development Workflow
Create a feature branch:
bashdaft start daft-XX/feature-nameMake changes following the conventions below.
Run quality checks before committing:
bashmise run fmt mise run clippy mise run test:unitSubmit a pull request targeting
master.
Code Quality Requirements
All PRs must pass these checks (enforced in CI):
- Formatting:
mise run fmt:check - Linting:
mise run clippy(zero warnings) - Unit tests:
mise run test:unit - Integration tests:
mise run test:integration(bash + YAML, full matrix)
Run the full CI simulation locally:
bash
mise run ciTesting
daft has two test systems that run in CI for each matrix entry (default + subprocess):
YAML manual tests (preferred for new tests)
Declarative test scenarios in tests/manual/scenarios/. Each YAML file defines repos to create, steps to run, and expectations to verify.
bash
mise run test:manual # Run all 581 scenarios (automatic, default)
mise run test:manual checkout # Run one command's tests (automatic)
mise run test:manual -- -i checkout:basic # Step through one scenario interactively
mise run test:manual -- --list # List all available scenariosEphemeral RAM-disk sandbox
When the dev box is IO-bound (especially running two worktree suites concurrently), swap in the RAM-backed sandbox:
bash
mise run test:manual:ramdisk # All scenarios, ephemeral RAM mount
mise run test:manual:ramdisk -- -j 4 # CLI args flow through to xtaskThe task allocates a per-run RAM volume (macOS: /Volumes/daft-ramdisk-test-<PID> via hdiutil/diskutil; Linux: /dev/shm/daft-ramdisk-test-<PID>), exports DAFT_MANUAL_TEST_BASE so the runner uses it, and tears the mount down on EXIT, INT, or TERM. Two concurrent shells get two independent mounts. SIGKILL leaks one mount per crash; recover with diskutil eject /Volumes/daft-ramdisk-test-* (macOS) or rm -rf /dev/shm/daft-ramdisk-test-* (Linux).
Configure with DAFT_RAMDISK_SIZE_MB (macOS only, default 4096; Linux tmpfs is kernel-sized). This does not replace the default test:manual task — it's an opt-in alternative. Unit tests for daft's filesystem-physical primitives (cow_copy, store::connection) use tempfile::tempdir() and aren't affected by DAFT_MANUAL_TEST_BASE, so real-disk coverage is preserved separately.
Cross-worktree shared daft binary
mise run test:manual (and the :ramdisk variant) automatically builds the daft and xtask binaries into a content-hashed location under .git/.daft-shared-bin/<state-hash>/, then points the test runner at it via DAFT_BINARY_DIR. Sibling worktrees at the same source state skip the build entirely and reuse the same binary — the most visible win is on the second concurrent worktree run, which used to pay the full release-build cost before any test could start.
The state hash covers HEAD plus the working-tree blob hash of every tracked *.rs file, every Cargo.toml, and Cargo.lock, so editing any workspace crate (including term-styles) invalidates the cache cleanly. Concurrent populate attempts publish atomically (rename(2) of the staging directory) so two simultaneous fresh-state runs never corrupt the cache — the loser cleans up its staging dir and re-uses the winner's binary.
To bypass the shared bin for a single run (e.g. to test a hand-built binary with custom features), set DAFT_BINARY_DIR explicitly:
bash
DAFT_BINARY_DIR="$(pwd)/target/release" mise run test:manualTo wipe the cache entirely (e.g. accumulated state-hash directories taking disk space):
bash
rm -rf "$(git rev-parse --git-common-dir)/.daft-shared-bin"The cache lives under $(git rev-parse --git-common-dir)/.daft-shared-bin/ (the repo's shared .git/ directory — the bare repo at the project root in daft's worktree layout, not the per-worktree .git/ file), so it goes away with the project, never under XDG state.
To add a new test, create a .yml file in the appropriate command directory:
yaml
name: Checkout basic
repos:
- name: test-repo
use_fixture: standard-remote
steps:
- name: Clone and checkout
run: git-worktree-clone $REMOTE_TEST_REPO
expect:
exit_code: 0
dirs_exist:
- "$WORK_DIR/test-repo/main"See tests/README.md for the full schema reference (assertions, variables, fixtures, path conventions).
Bash integration tests
Shell-based tests in tests/integration/. The master runner test_all.sh executes all suites.
bash
mise run test:integration # Full matrix (default + subprocess)
cd tests/integration && bash test_clone.sh # Run one suite directlyDev sandbox
The sandbox provides an isolated environment for manual testing of daft commands. Each worktree gets its own sandbox with a clean git identity and locally-built binaries on PATH.
bash
mise run sandbox # Enter the sandbox shell
mise run sandbox:activate # Install sandbox shell function into RC file
mise run sandbox:clean # Remove sandbox for this worktreeInside the sandbox you can set up test scenarios and explore them interactively:
bash
# Set up a test scenario and cd into its working directory
mise run test:manual -- --setup-only checkout:basic
# Reset the test environment to its initial state
mise run test:manual -- --setup-only --step 1 checkout:basicConfig isolation
Dev builds keep their daft state out of your real config, so day-to-day development never touches the daft you have installed. Two layers handle this, both checked into the repo — they work the moment you clone, with no per-machine setup:
- Shared across worktrees.
mise.tomlsourcesshared-env.sh, which exportsDAFT_CONFIG_DIR=<git-common-dir>/.daft-sandbox. Every mise-activated shell in every worktree — including plaincargo runand./target/release/daftinvocations — reads and writes one shared trust DB,repos.json, and layout config there. - Isolated per worktree.
mise run sandboxgoes a step further, pointingDAFT_CONFIG_DIR,DAFT_DATA_DIR, andDAFT_STATE_DIRat a/tmp/daft-sandbox-<hash>unique to the current worktree (seemise-tasks/sandbox/_lib.sh).
These overrides are honored only in dev builds (gated on cfg!(any(daft_dev_build, test)) in src/lib.rs). A build is "dev" when it is compiled from a git checkout without DAFT_BUILD_RELEASE set; the release pipeline sets that variable, so a released daft ignores the overrides and always uses the real XDG directories (~/.config/daft and friends) — your installed daft and your dev work never share state. Git tags at HEAD play no part in the detection (#669: tag-based detection used to silently disable the overrides in every post-release window).
Benchmarks
Compare bash and YAML test performance with a live TUI table:
bash
mise run bench:tests:integration # Sequential (default)
mise run bench:tests:integration -- --parallel # Parallel bash+YAML per suiteCommit Messages
This project uses Conventional Commits for automatic changelog generation.
Format: <type>[scope]: <description>
| Type | Description |
|---|---|
feat | New feature |
fix | Bug fix |
docs | Documentation changes |
style | Code style (no logic change) |
refactor | Code refactoring |
perf | Performance improvement |
test | Adding/updating tests |
chore | Maintenance tasks |
ci | CI/CD changes |
Examples:
bash
feat(checkout): add --force flag for overwriting worktrees
fix: resolve branch name parsing for names with slashes
docs: update installation instructionsPull Request Guidelines
- PR titles use conventional commit format:
feat: add dark mode toggle - Issue references go in the PR body, not the title:
Fixes #42 - All PRs target
masterand are squash-merged
Labels
Issues and PRs are organized on a few orthogonal axes:
- Kind (issues):
bug,enhancement,documentation - Type (PRs): the conventional-commit type —
feat,fix,docs,perf,refactor,chore,ci,style,test - Area (
area:*): the part of the codebase a change touches (e.g.area:worktree,area:hooks,area:store,area:docs,area:ci). On PRs these are applied automatically from the changed files — you don't add them by hand. They make conflict-prone overlaps visible: two open PRs sharing anarea:are likely to need a rebase. - Topic / triage:
security,audit,release,high-priority, and so on
When filing an issue, add its kind and, if you know it, the area:* you expect the work to touch. The zone map is defined in .github/labeler.yml.
Branch Naming
Follow the convention: daft-<issue-number>/<short-description>
daft-42/dark-mode
daft-15/branch-searchAdding a New Command
- Create
src/commands/<name>.rswith a clapArgsstruct (includeabout,long_about,arg(help)attributes) - Add the module to
src/commands/mod.rs - Add routing in
src/main.rs - Add to
COMMANDSarray andget_command_for_name()inxtask/src/main.rs - Add to help output in
src/commands/docs.rs(get_command_categories()) - Run
mise run man:genandmise run docs:cli:genand commit the generated files - Add YAML test scenarios in
tests/manual/scenarios/<name>/ - Add bash integration tests in
tests/integration/
License
daft is dual-licensed under MIT (LICENSE-MIT) and Apache-2.0 (LICENSE-APACHE) to match Rust-ecosystem norms (Apache-2.0 carries an explicit patent grant).
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the project by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Dependency licenses
daft enforces a permissive-license allowlist on every transitive crate via cargo-deny. The policy lives in deny.toml at the repo root and is gated in CI (.github/workflows/test.yml) on every PR that touches Cargo.toml, Cargo.lock, or deny.toml. The same check also fails the PR on any open RustSec advisory or a dep sourced from an unexpected registry.
If a new dependency carries a license not on the allowlist (or you need to ignore an advisory), add a [licenses.exceptions] or advisories.ignore entry to deny.toml with a # why: comment explaining the rationale. Run mise run deny locally to verify before pushing.