Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

What is Guisu?

Guisu is a Rust-based dotfile manager. It helps you keep your configuration files (dotfiles) under version control and apply them across multiple machines, with support for templates, encryption, secret managers, hooks, and a three-way merge that detects when files have been edited locally.

Note

Early development Guisu is pre-1.0. APIs and behaviour may change between minor versions. Pin to a specific version if you depend on it.

Why a new dotfile manager?

Guisu exists because the existing tools are good but each leaves room for improvement in one dimension:

DimensionGuisuChezmoi
ImplementationRust, single static binaryGo, with cgo for some features
EncryptionBuilt-in (age crate)External age binary, or built-in
GitBuilt-in (git2 crate)External git binary, or built-in
Databaseredb (pure Rust)BoltDB (cgo)
Template engineminijinja (Jinja2-compatible)Go text/template
Type safetyCompile-time path types (AbsPath, RelPath)Runtime string checks
Typical binary size3-5 MB~20 MB

The goal is chezmoi parity with a smaller, faster, more self-contained binary and a friendlier API for embedding Guisu as a library.

The three-state model

Guisu manages every dotfile through three states:

Source state          Target state         Destination state
(repository)          (after processing)   (actual files)
     ↓                      ↓                     ↓
  .bashrc.j2     →     .bashrc (rendered)  →  ~/.bashrc
  key.txt.age    →     key.txt (decrypted) →  ~/key.txt

A persistent redb database tracks the content hash of the target state that was last successfully applied. On the next run, Guisu compares the new target against the on-disk destination against the database and produces a status for each file: Synced, Added, Modified, Removed, or Conflict. The interactive --interactive mode lets you resolve conflicts in a TUI.

Inspiration

Guisu is heavily inspired by chezmoi and shares its directory layout, file-attribute conventions, and overall feel.

Installation

From source

Requires the Rust toolchain (edition 2024, stable):

git clone https://github.com/YvanY0/guisu.git
cd guisu
cargo install --path crates/cli

The resulting binary is at ~/.cargo/bin/guisu. Add ~/.cargo/bin to your $PATH if it is not already.

Binary releases

Pre-built binaries for macOS, Linux, and Windows are linked from the releases page once they ship. Each release archives a static binary plus a SHA-256 checksum.

Verifying the install

guisu --version
guisu info

The info command prints the resolved source directory, working tree, and destination directory so you can confirm Guisu sees your repository correctly. Add --all for build info, version, public keys, and configuration; add --json for machine-readable output.

Tip

Check the source dir guisu info will print the resolved source dir, which is . if you run it inside a source repo, or ~/.local/share/guisu if you do not. If the resolved value is not what you expect, fix it with guisu init or by setting [general] src_dir in your .guisu.toml.

Next step

Getting Started — init.

Getting Started — init

guisu init is the entry point: it obtains a source repository (by cloning from GitHub or by initialising one locally) and, by default, runs an apply to materialise your dotfiles.

Clone an existing GitHub repository

guisu init username                       # shorthand for github.com/username/dotfiles
guisu init https://github.com/username/dotfiles.git
guisu init --ssh username                 # use SSH instead of HTTPS
guisu init --depth 1 username             # shallow clone (faster, no history)
guisu init --no-apply username            # clone but do not apply yet

guisu init clones into the source directory (default ~/.local/share/guisu) and then runs guisu apply. On the first run, apply uses interactive mode by default so you can review the changes before they land in your home directory.

Initialise a brand-new local repository

mkdir ~/dotfiles && cd ~/dotfiles
git init
cd ..
guisu init

This creates a new directory, initialises git inside it, and writes a starter .guisu.toml. The next guisu add knows where to put files.

Note

Where does the source go? By default, Guisu uses ~/.local/share/guisu as the source directory — the same convention as chezmoi. Override per-call with --source or globally in .guisu.toml under [general] src_dir.

Where things go

PathDefaultOverride
Source directory~/.local/share/guisu--source / [general] src_dir
Destination directory$HOME--dest / [general] dst_dir
Persistent state DB<source>/.guisu-state.db(not configurable)
Editor (for guisu edit)$EDITOR or [general] editorenv var or config

What init does not do

init does not commit any uncommitted changes. After cloning, the source repo is exactly as the remote left it; if you later guisu add a file, that change is staged in git but not committed.

Next step

Getting Started — add.

Getting Started — add

guisu add copies a file or directory from your destination into the source repository. It strips the $HOME prefix and preserves the rest of the path verbatim.

Basic usage

guisu add ~/.bashrc                       # copy ~/.bashrc → source as .bashrc
guisu add ~/.config/nvim                 # copy a directory recursively
guisu add --encrypt ~/.ssh/id_rsa         # add as age-encrypted .age file
guisu add --template ~/.gitconfig         # add as a template (.j2)
guisu add ~/.config/nvim/init.vim         # add a single file inside a directory
guisu add --no-git ~/.zshrc                # add but do not run `git add`

add runs git add on the resulting source path by default. Pass --no-git to skip that step.

Re-adding an existing source file

guisu add --force ~/.bashrc

--force overwrites the source file with the destination’s current content. The --encrypt and --template flags compose with --force to re-add with the same attributes.

Warning

–force overwrites silently --force does not prompt. If you have local changes in the source repo (template edits, secret rotations), they are lost. Use guisu diff first to see what would change.

Common flags

FlagEffect
--encryptStore the file as <name>.age in the source.
--templateStore the file as <name>.j2 for Jinja rendering.
--privateForce mode 0600 on apply.
--executableForce mode 0755 on apply.
--exactPreserve the source filename as-is (no attribute inference).
--forceOverwrite the existing source entry.
--no-gitSkip the git add step.

Transformations you can expect

InputSource fileOn apply
~/.bashrc.bashrc~/.bashrc (verbatim)
~/.ssh/config.ssh/config~/.ssh/config (verbatim)
~/.ssh/id_rsa + --encrypt.ssh/id_rsa.age~/.ssh/id_rsa (decrypted, mode 0600)
~/.config/nvim/init.vim + --template.config/nvim/init.vim.j2~/.config/nvim/init.vim (rendered)

Next step

Getting Started — apply.

Getting Started — apply

guisu apply materialises the source state into the destination directory, applying templates, decrypting, and respecting conflict rules. It is the command you will run most often — usually via guisu update which is git pull + apply.

Basic usage

guisu apply                 # apply everything
guisu apply --dry-run       # show what would change, do not write
guisu apply --interactive   # prompt for conflicts in a TUI
guisu apply --force         # overwrite destination without asking
guisu apply path1 path2     # apply only specific paths
guisu apply --include 'dot_*' --exclude '*.tmp'

How status is determined

For each file, Guisu compares three sources of truth:

  1. Target — the rendered, decrypted content the source state wants.
  2. Destination — the file currently on disk.
  3. Database — the content hash stored the last time apply succeeded (in <source>/.guisu-state.db).

The result is one of Synced, Added, Modified, Removed, or Conflict.

TargetDestinationDatabaseStatusDefault action
AAASyncedSkip
ABAModified (by you)Overwrite
AABModified (in source)Apply
ABCConflictPrompt (--interactive) or overwrite
AAddedCreate
BBRemovedDelete
BAModified + RemovedConflict

Interactive mode

--interactive opens a TUI for every conflict, showing a side-by-side diff and four actions: Overwrite, Skip, View Diff, Quit.

guisu apply --interactive

Tip

Pipe-friendly output guisu apply --dry-run prints a list of planned changes. Combine with guisu info for a complete preview before a real apply.

Exit codes

CodeMeaning
0All changes applied (or nothing to do).
1An error occurred.
Non-zero with --dry-runA planned change would have been made.

Hooks

apply runs pre- and post-hooks from .guisu/hooks/{pre,post}/{always,once,onchange}/ around the apply. See Hooks.

Next step

Read the User Guide for the file-attribute conventions, or jump to Templates if your dotfiles need environment-specific rendering.

File Attributes

Guisu derives per-file behaviour from three sources, in this order of precedence:

  1. The source file’s actual Unix mode bits (read from disk via metadata().mode()). These are the source of truth for permissions and are propagated verbatim to the destination by apply.
  2. The file extension on the source filename (.j2, .age, .j2.age). The extension is stripped from the target name.
  3. The [remove] paths array in .guisu/state.toml, which declares dest paths to remove on apply.

Extensions (transformation markers)

SuffixMeaningResult on apply
.j2Jinja2 templateFile is rendered with the template engine before write
.ageage-encryptedFile is decrypted before any other processing
.j2.ageEncrypted templateDecrypt first, then render

Extensions stack in the order .j2.age (decrypt, then render). The reverse order .age.j2 is not valid — the renderer would run on the encrypted bytes.

File permissions

The destination file’s mode is the source file’s mode. If ~/.local/share/guisu/home/.config/zsh/zshrc is 0o644 on disk, the rendered ~/.config/zsh/zshrc will be 0o644 after guisu apply. guisu diff reports any mismatch between source and dest mode bits. guisu apply chmods the destination to match the source on every run.

This is a deliberate departure from chezmoi: guisu does not encode permissions in the filename. There is no private_ or executable_ prefix — if you want a private file, make the source file private (chmod 600 path/to/source) and apply will propagate it. Keeping the filename free of permission markers means a dotfiles repo can be shared across machines with different UIDs without the filename lying about the resulting mode.

Removing a file from the destination

To remove a file or directory from the destination on apply, list it under [remove] paths in .guisu/state.toml:

[remove]
paths = [
  "~/.cache/foo",
  "~/.config/old-app",
]
  • Paths are dest-relative under the destination root. ~ is expanded to the user’s home; absolute paths and .. traversal are rejected as a safety check.
  • Missing paths are silently ignored (idempotent directive).
  • --dry-run reports the removal without touching the filesystem.

The legacy remove_xxx filename convention is not supported — the only way to declare a removal is via state.toml. There is currently no CLI command for editing the remove list; edit state.toml by hand.

Directories

The same rules apply. A directory’s mode is read from the source and propagated to the destination. A directory with no source counterpart (an “extra” directory in the dest) is left alone unless it is listed under [remove].

See also

  • Templates — how .j2 files are rendered.
  • Encryption — how .age files are decrypted.
  • Hooks — pre/post scripts that run around apply.
  • Config — how .guisu/state.toml is loaded.

Templates

Guisu renders .j2 files with minijinja, which implements the Jinja2 template language. Templates are evaluated after any .age decryption, so an encrypted template (.j2.age) is decrypted first and then rendered.

Variables available in templates

The template context is composed from four sources:

SourceKeys
Systemos, arch, hostname, username, home_dir
Environment(read with the env function: env("HOME"))
Guisusource_dir, working_tree, dest_dir, config
Userevery key from .guisu.toml [variables] and from .guisu/variables/*.toml and platform-specific subdirectories

Example

# ~/.bashrc — rendered on {{ os }} ({{ arch }})
{% if os == "darwin" %}
export HOMEBREW_PREFIX="/opt/homebrew"
{% elif os == "linux" %}
export HOMEBREW_PREFIX="/home/linuxbrew/.linuxbrew"
{% endif %}

export EDITOR="{{ editor }}"
export EMAIL="{{ email }}"

Built-in template functions

The full list is in Reference — Template Functions. The most-used ones:

FunctionPurpose
env("NAME")Read an environment variable (empty string if unset).
lookPath("cmd")Absolute path of cmd on $PATH, or empty.
bitwarden("Item")Fetch a Bitwarden item as an object.
bitwardenFields("Item", "Field")Fetch a specific field.
include("name")Include the raw content of another template file.
includeTemplate("name")Include and render a template file.
decrypt(value) (filter)Decrypt an inline age:base64,... string. Used as `{{ ‘value’
encrypt(value) (filter)Encrypt a value for the configured recipients.
joinPath(parts...)Join path components portably.

Tip

Cache expensive lookups Vault calls (bitwarden, pass, etc.) shell out to an external process. The vault layer caches the response for the duration of a single apply so repeated lookups in the same template are cheap. Cross-apply caching is not implemented — every apply re-fetches.

Platform-specific variables

Variables can be split across multiple files under .guisu/variables/:

.guisu/variables/
├── user.toml              # global
├── visual.toml            # global
├── darwin/
│   ├── git.toml
│   └── terminal.toml
└── linux/
    ├── git.toml
    └── terminal.toml

Loading order:

  1. Global files (any *.toml directly in .guisu/variables/).
  2. Platform-specific files from <os>/.

The merge is per section: a platform-specific [git] table is merged into the global [git] table, key by key, rather than overwriting the whole table. This means you can override one key in darwin/git.toml without re-listing the others.

Templated configuration

A .guisu.toml.j2 is itself rendered with the same context before being parsed. This lets you vary configuration per machine without committing a per-machine file.

See also

Encryption

Guisu uses age for symmetric and asymmetric encryption. Files marked with the .age suffix are decrypted on apply.

Warning

Do not commit your age key The age identity file is the only thing that can decrypt your secrets. Never commit it to the dotfiles repo; keep it in ~/.config/guisu/ (which is outside the source) or in a hardware token. If you lose the identity, the encrypted files are gone.

Generate an identity

guisu age generate -o ~/.config/guisu/key.txt

This writes a native age key. To use an existing SSH key instead, point .guisu.toml at it:

[age]
identity = "~/.ssh/id_ed25519"
derive = true   # derive the recipient from the public key for encryption

When derive = true, Guisu uses the SSH public key as an age recipient. You can encrypt to the SSH public key, and the SSH private key acts as the age identity for decryption.

Add an encrypted file

guisu add --encrypt ~/.ssh/id_rsa

The source file is id_rsa.age (ASCII-armored). On apply, it is decrypted to ~/.ssh/id_rsa with mode 0600, because Guisa infers the private_ prefix from the destination path .ssh/....

Edit an encrypted file

guisu edit ~/.ssh/id_rsa

Guisu decrypts to a temp file (mode 0600), opens your $EDITOR, then re-encrypts and replaces the source on save. The temp file is securely deleted when the editor exits.

Warning

Editor backups and swap files Your editor may leave backup files (~/.ssh/id_rsa~, .swp, etc.) on disk. Configure your editor to disable backups (set nobackup nowritebackup noswapfile in vim) or run guisu edit from a tmpfs-backed directory.

Inline encryption in templates

For small secrets (API tokens, etc.) you can encrypt inline and embed in a template:

export GITHUB_TOKEN="{{ 'age:base64,YWdl...' | decrypt }}"

Generate an inline value with:

guisu age encrypt --inline 'ghp_xxxxxxxxxxxx'

The output is safe to commit. Decryption happens at render time and the plaintext only ever exists in memory.

Multiple recipients

[age]
recipients = [
    "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p",
    "age1j0p6m6j3xcfua6jn8u6vnn7qk8h0qg5k7z2q3w..."
]
identity = "~/.config/guisu/key.txt"

Guisu encrypts to every recipient; any one identity can decrypt. Use this to give multiple machines access to the same secrets without sharing a private key.

See also

Vault (Secret Providers)

Guisu can pull secrets from a password manager CLI and expose them in templates. The built-in integrations are Bitwarden (bw), Roboform-bitwarden (rbw), and Bitwarden Secrets (bws).

Warning

Authentication is your responsibility Guisu does not log you in to the password manager. You must run rbw login, bw login, or bws login (or set the appropriate env var) once per session before guisu apply. If the vault CLI is unauthenticated, the call will fail with a non-zero exit code.

Configure a provider

[bitwarden]
provider = "rbw"   # or "bw", "bws"

The provider binary must be on $PATH and authenticated. There is no per-provider feature flag — all three providers compile in by default.

Use in a template

export GITHUB_TOKEN="{{ bitwarden('GitHub').login.password }}"
export API_KEY="{{ bitwardenFields('GitHub', 'APIKey') }}"

The first call returns the full item as a structured object; the second returns a specific custom field. Both are JSON-typed — Jinja2’s dot syntax and [] indexing both work.

Caching

The vault layer caches the response for the duration of a single apply. If your template calls bitwarden("GitHub") three times, the underlying CLI is invoked once. The cache is per-apply and is dropped at the end of the run.

Tip

Reference items by stable name Password manager item names are user-defined and can change. If you rename an item in Bitwarden, the template will fail at the next apply because the lookup returns null. Treat item names as part of your template contract.

Adding a new provider

See the add-vault-provider skill in the repository. New providers implement the SecretProvider trait in guisu-vault:

#![allow(unused)]
fn main() {
pub trait SecretProvider: Send + Sync {
    fn name(&self) -> &str;
    fn is_available(&self) -> bool;
    fn execute(&self, args: &[&str]) -> Result<serde_json::Value>;
    fn help(&self) -> &str;
}
}

The CLI for the provider must return JSON; Guisu parses it as serde_json::Value and makes it available to the template.

See also

  • Templates for the broader template engine.
  • Encryption for committing encrypted secrets instead of fetching them.

Hooks

Hooks are scripts that run before, after, or on change of an apply. They live under .guisu/hooks/ in the source repository, and they are version-controlled alongside your dotfiles.

Layout

.guisu/hooks/
├── pre/
│   ├── always/
│   ├── once/
│   └── onchange/
└── post/
    ├── always/
    ├── once/
    └── onchange/
  • pre/ runs before the apply begins.
  • post/ runs after the apply completes successfully.
  • always/ runs every time.
  • once/ runs at most once per script (recorded in the persistent state).
  • onchange/ runs when the script’s blake3 hash has changed since the last run.

Filename ordering

pre/always/10-install-packages.sh runs before pre/always/20-configure-shell.sh. Number prefixes control order; ties break by filename (lexicographic).

Script interpreter

The script interpreter is auto-detected from the shebang line. #!/bin/bash, #!/usr/bin/env python3, #!/usr/bin/env nix-shell, etc. all work. If there is no shebang and the file is not executable, the script fails.

Template rendering

Hook commands are rendered as templates before execution. {{ os }}, {{ guisu.source_dir }}, {{ guisu.working_tree }}, etc. are available. This is how you can branch on platform inside a single hook file:

#!/bin/bash
{% if os == "darwin" %}
brew update
{% elif os == "linux" %}
sudo apt-get update
{% endif %}

Platform filtering

You can place hooks under a platform subdirectory to scope them:

.guisu/hooks/pre/always/darwin/10-install-brew.sh
.guisu/hooks/pre/always/linux/10-install-apt.sh

Only the directory matching the current platform is executed. The hook in the unfiltered pre/always/ directory runs on every platform.

Environment variables

When a hook is invoked, the following variables are set:

VariableValue
GUISU_SOURCE_DIRAbsolute path to the source repo
GUISU_DEST_DIRAbsolute path to the destination root
GUISU_WORKING_TREEPath of the dotfiles working tree
GUISU_PHASEpre or post
GUISU_MODEalways, once, or onchange
GUISU_SCRIPTAbsolute path to the script being run
GUISU_TARGET(modify_ files only) The destination path the script should modify

See also

Configuration

Guisu reads .guisu.toml from the source directory. The file can itself be a template (.guisu.toml.j2), which lets you vary config per machine without committing per-machine files.

Quick start

[general]
color = true
progress = true
editor = "nvim"

[age]
identity = "~/.config/guisu/key.txt"
derive = true

[bitwarden]
provider = "rbw"

[variables]
email = "user@example.com"
editor = "nvim"

[ignore]
global = [".git", ".DS_Store"]
darwin = ["Thumbs.db"]
linux = ["*~"]

Sections

SectionPurpose
[general]Top-level behaviour: paths, output, editor.
[age]Encryption identity and recipients.
[bitwarden]Password-manager provider selection.
[ui]Pager and diff tool overrides.
[ignore]Files to skip, with per-platform variants.
[variables]Free-form key/value map exposed as template variables.
[hooks](Optional) Hook execution defaults.

The full schema with type, default, and notes per key is in Reference — Configuration.

Templated configuration

A .guisu.toml.j2 is rendered with the same context as a regular template. Use this for per-machine values:

[general]
editor = "{{ env("EDITOR") | default(value="nvim") }}"

[variables]
hostname = "{{ hostname }}"

The file is rendered before being parsed, so the resulting TOML is what the rest of the system sees.

Note

Config file is rendered with the same context as templates hostname, os, arch, env vars, and user variables are all available in .guisu.toml.j2. This means a config file can be both a config and a small template at the same time.

Validation

guisu info prints the resolved configuration. Use it to debug:

guisu info --all

guisu info prints the resolved configuration, the version, public keys, and a validation summary. Use --json for machine-readable output. There is no separate guisu config get / guisu config set subcommand — edit .guisu.toml directly.

See also

Commands

This page enumerates every guisu subcommand. Run guisu <subcommand> --help for the authoritative flag list; this page is the curated overview.

Note

How the page is organised Top-level commands first, then sub-commands grouped under their parents (age, ignored, templates, hooks). Flags shown are the curated highlights; the full set comes from --help.

Top-level commands

CommandPurpose
guisu init [TARGET]Clone a dotfiles repo or initialise a new one locally.
guisu add PATH...Copy files from the destination into the source repo.
guisu apply [PATH]...Render the source state and write to the destination.
guisu diffShow the differences between target and destination.
guisu statusPer-file status (Synced / Added / Modified / Removed / Conflict).
guisu cat PATHPrint the rendered (target) content of a file.
guisu edit PATHOpen the source file in $EDITOR with transparent decrypt/encrypt.
guisu updateFetch, merge, and apply.
guisu infoPrint resolved source / destination / version / configuration.
guisu variablesPrint all template variables available in the current context.
guisu ageSee age subcommand group.
guisu ignoredSee ignored subcommand group.
guisu templatesSee templates subcommand group.
guisu hooksSee hooks subcommand group.

guisu init

guisu init [PATH_OR_REPO] [FLAGS]
FlagEffect
--apply / -aApply changes after initialisation (default behaviour for cloned repos).
--depth NShallow clone with the given commit depth.
--branch NAMEClone the specified branch.
--sshUse SSH instead of HTTPS when guessing the GitHub URL.
--recurse-submodulesClone submodules recursively.

PATH_OR_REPO accepts a local path, a GitHub username (shorthand for github.com/username/dotfiles), or a full URL. Defaults to ~/.local/share/guisu.

guisu add

guisu add PATH... [FLAGS]
FlagEffect
--encryptStore the file as <name>.age in the source.
--templateStore the file as <name>.j2 for Jinja rendering.
--privateForce mode 0600 (or 0700 for directories).
--executableForce mode 0755.
--exactPreserve the source filename as-is; do not infer attributes.
--forceOverwrite an existing source entry.
--no-gitSkip the git add step.

guisu apply

guisu apply [PATH]... [FLAGS]
FlagEffect
--dry-runShow planned changes without writing.
--forceOverwrite destination without prompting.
--interactiveOpen the TUI for every conflict.
--include PATTERNOnly apply files matching the pattern.
--exclude PATTERNSkip files matching the pattern.
--no-apply(Used by init.) Skip the apply step.

guisu diff

guisu diff [PATH]... [FLAGS]

Shows a unified diff between target and destination. Honours --include / --exclude and the same source/destination overrides as apply.

guisu status

guisu status [PATH]... [FLAGS]

Per-file status output, one line per entry. --json emits machine-readable output.

guisu cat

guisu cat PATH

Print the rendered (decrypted + templated) target content to stdout. Useful for debugging templates without applying.

guisu edit

guisu edit PATH

Decrypt (if .age), open in $EDITOR, re-encrypt on save. Temp file is 0600 and securely deleted on editor exit.

guisu update

guisu update [FLAGS]

Fetch from the source repo’s remote, merge (or rebase with --rebase), and run apply. --no-apply skips the apply step.

guisu info

guisu info [FLAGS]

Print the resolved source directory, working tree, destination directory, and version. With --all, also print build info, public keys, and configuration. With --json, machine-readable.

Tip

Use info for first-time setup debugging If guisu apply cannot find your source dir, run guisu info --all to see what Guisu resolved. Most “why is nothing happening?” questions answer themselves in this output.

guisu variables

guisu variables [FLAGS]

Print every variable available in the current template context: system, guisu, user-defined, and platform-specific. --builtin shows only system + guisu; --user shows only user-defined; --json emits machine-readable.

Subcommand groups

guisu age

SubcommandPurpose
guisu age generate -o FILECreate a new age identity.
guisu age encrypt FILEEncrypt a file with the configured recipients.
guisu age decrypt FILEDecrypt a file with the configured identities.
guisu age encrypt --inline VALUEEmit an inline-encrypted string for use in templates.
guisu age decrypt --inline VALUEDecrypt an inline-encrypted string.
guisu age recipientsList the configured recipients.

guisu ignored

SubcommandPurpose
guisu ignored listList currently ignored files and the patterns that matched.
guisu ignored add PATTERNAdd a pattern to .guisu.toml [ignore].
guisu ignored remove PATTERNRemove a pattern.

guisu templates

SubcommandPurpose
guisu templates execute PATHRender a template with the current context (no apply).

guisu hooks

SubcommandPurpose
guisu hooks run PHASEManually execute pre or post hooks for the given phase.
guisu hooks listList all discovered hooks with their resolved mode.
guisu hooks show HOOKShow the script path and the environment variables it would receive.

Global flags

Most top-level commands accept:

FlagEffect
--source DIROverride the source directory. Also reads GUISU_SOURCE_DIR.
--dest DIROverride the destination directory. Also reads GUISU_DEST_DIR.
--config FILEOverride the config file path. Also reads GUISU_CONFIG.
--log-file FILEMirror logs to this file. Also reads GUISU_LOG_FILE.
--color WHENauto / always / never.
--progress WHENauto / always / never.

Template Functions

The full list of functions and filters registered with the minijinja environment, grouped by category. Run guisu variables to inspect what is available in a given context.

Note

Functions vs filters A function is called with parentheses: fn(arg). A filter is applied with the pipe operator: value | filter. The same underlying operation may exist as both — see the encryption row below.

System

FunctionReturnsNotes
os()string"darwin", "linux", or "windows".
arch()string"x86_64", "aarch64", "arm".
hostname()stringMachine hostname.
username()stringCurrent OS user.
home_dir()string$HOME.

Environment

FunctionReturnsNotes
env("NAME")stringEmpty string if unset.
lookPath("cmd")stringAbsolute path of cmd on $PATH, or empty if not found.

Paths

FunctionReturnsNotes
joinPath("a", "b", "c")stringJoin path components portably.

Vault (Bitwarden)

All four functions are available when the [bitwarden] provider is configured. Caching is per-apply.

FunctionReturnsNotes
bitwarden("Item")objectThe full Bitwarden item as a JSON object.
bitwardenFields("Item", "Field")valueA specific custom field on the item.
bitwardenAttachment("Item", "filename")stringAn attachment’s contents as a string.
bitwardenSecrets("Item", "Field")stringA secret field (Bitwarden Secrets only).

Templates

FunctionReturnsNotes
include("name")stringThe raw content of another template file.
includeTemplate("name")stringInclude and render a template file with the current context.

Encryption

These are filters (not functions) — they go on the right side of a |.

FilterReturnsNotes
value | decryptstringDecrypt an inline age:base64,... string.
value | encryptstringEncrypt a string for the configured recipients.
export TOKEN="{{ 'age:base64,YWdl...' | decrypt }}"

Strings

FunctionReturnsNotes
regexMatch(pattern, string)boolTrue if the regex matches anywhere.
regexReplaceAll(pattern, replacement, string)stringReplace all matches.
split(separator, string)listSplit a string into a list.
join(separator, list)stringJoin a list into a string.

String filters

FilterReturnsNotes
s | quotestringSurround with double quotes; escape inner quotes.
s | trimstringStrip leading and trailing whitespace.
s | trimStartstringStrip leading whitespace.
s | trimEndstringStrip trailing whitespace.
s | customstringUppercase the string. (Built-in alias; useful for templates.)

Data formats

FilterReturnsNotes
value | toJsonstringSerialise to JSON.
s | fromJsonvalueParse JSON.
value | toTomlstringSerialise to TOML.
s | fromTomlvalueParse TOML.

Hashing

FilterReturnsNotes
s | blake3sumstringHex-encoded BLAKE3-256 of the input.

Tip

Generated inline values Run guisu age encrypt --inline "my secret" to print an inline-encrypted value. The output can be committed safely and embedded in templates as {{ 'value' | decrypt }}.

See also

Configuration Reference

The full schema of .guisu.toml. Every key is optional; Guisu applies a default if a key is missing. The file is rendered as a Jinja template if its name is .guisu.toml.j2.

Note

Derived from source This page is derived from crates/config/src/config.rs and crates/config/src/ignores.rs. The authoritative list is the source — if a key is added or removed, the source wins.

[general]

KeyTypeDefaultNotes
src_dirpath~/.local/share/guisuSource repository location.
dst_dirpath~Destination root.
root_entrypath(resolved at runtime)Every target path is relative to this subpath.
colorbooltrueANSI colour in output.
progressbooltrueProgress bars.
editorstring$EDITOREditor used by guisu edit.
editor_argslist of string[]Extra args passed to the editor.
use_builtin_ageenumautoauto / true / false. Use the in-process age implementation.
use_builtin_gitenumautoauto / true / false. Use the in-process git implementation.

[age]

KeyTypeDefaultNotes
identitypathSingle age or SSH identity file.
identitieslist of path[]Multiple identity files; any one can decrypt.
recipientstringSingle age recipient.
recipientslist of string[]Multiple recipients; encryption writes to all.
deriveboolfalseDerive a recipient from the SSH identity for encryption.
fail_on_decrypt_errorboolfalseFail apply if a .age file cannot be decrypted. The default is to log a warning and skip.

[bitwarden]

KeyTypeDefaultNotes
providerstring"rbw"One of bw, rbw, bws.

[ui]

KeyTypeDefaultNotes
iconsenumautoauto / text / symbols. How status and diff mark file types.
diff_formatstring"unified"One of unified, side-by-side. Used by the conflict TUI.
context_linesinteger3Lines of context around a change in diff output.
preview_linesinteger10Lines of preview in the conflict TUI.

[edit]

KeyTypeDefaultNotes
applybooltrueWhether guisu edit should apply after a successful save. Set to false for read-only editing of templates.

[ignore]

KeyTypeDefaultNotes
globallist of string[]Always ignored, regardless of platform.
darwinlist of string[]macOS-only ignores.
linuxlist of string[]Linux-only ignores.
windowslist of string[]Windows-only ignores.

Patterns are gitignore-style: glob with *, ?, **, negation with !, and trailing / for directory-only.

[variables]

Free-form key/value map. Every key is exposed as a top-level variable in the template context. See User Guide — Templates.

[variables]
email = "user@example.com"
editor = "nvim"
work = true
servers = ["srv1", "srv2", "srv3"]

Resolving the effective config

  1. Defaults (compiled in).
  2. .guisu.toml in the source directory (rendered as template if .guisu.toml.j2).
  3. Per-platform files in .guisu/ignores/{darwin,linux,windows}.toml (only for [ignore]).
  4. Environment variables (GUISU_SOURCE_DIR, GUISU_DEST_DIR, GUISU_CONFIG, GUISU_LOG_FILE).
  5. Command-line flags.

Run guisu info --all to see the merged result.

See also

Architecture

Guisu is a Cargo workspace with 7 crates organised in strict layers. Higher layers depend on lower layers, never the reverse. There are no circular dependencies; cargo tree --workspace --no-dedupe confirms it.

Dependency table

The arrows below are read as “depends on”. The table lists every cross-crate dependency; an absent row means the crate is fully self-contained.

↓ depends on →corecryptovaulttemplateconfigenginecli
core (Layer 0)
crypto (Layer 1)
vault (Layer 1)
template (Layer 2)
config (Layer 2)
engine (Layer 3)
cli (Layer 4)

Read row-by-row:

  • core has no dependencies (only std).
  • crypto and vault depend only on core.
  • template depends on core, crypto, vault (so it can call encryption and vault lookups during rendering).
  • config depends on core, crypto, template (so .guisu.toml.j2 can render with the same context as regular templates).
  • engine depends on all of core, crypto, vault, template, config (it orchestrates the apply pipeline).
  • cli depends on every other crate (it is the user-facing binary).

Layer responsibilities (one-line summary)

CrateLayerRole
guisu-core0Newtype path types, platform detection, error types. Only depends on std.
guisu-crypto1age identity loading, encryption, decryption, file + inline.
guisu-vault1SecretProvider trait; built-in bw and rbw in bw.rs, bws in bws.rs.
guisu-template2minijinja env, ~30 functions and filters, platform-aware context.
guisu-config2.guisu.toml loading, per-platform variables, path resolution.
guisu-engine3Three-state model, persistent redb state, parallel processing.
guisu-cli4clap argument parsing, command implementations, conflict TUI.

Module map

guisu-engine is the largest crate. Its src/ layout is:

FileWhat it contains
state.rsSourceState, TargetState, DestinationState, PersistentState trait, redb-backed implementation.
entry.rsSourceEntry, TargetEntry, DestEntry, EntryKind enums.
attr.rsFileAttributes (bitflags: DOT, PRIVATE, READONLY, EXECUTABLE, TEMPLATE, ENCRYPTED).
content.rsThe raw byte pipeline: read, decrypt, render.
processor.rsContentProcessor<D, R> — generic decrypt + render pipeline.
database.rsredb table definitions.
hash.rsBLAKE3 helpers.
modify.rsIn-place modification of modify_* files.
system.rsPlatform-specific destination state reads.
validator.rsCross-state validation (e.g. is the source well-formed?).
git.rsIn-process git operations (init, fetch, merge) using git2.
hooks/Pre/post/once/onchange hook discovery and execution.
adapters/Adapters to alternative implementations (e.g. an HTTP-based SourceState reader for tests).

Why strict layers?

The strict layering buys two things:

  1. Bounded compile time. A change to guisu-core rebuilds the whole workspace; a change to guisu-cli rebuilds only guisu-cli and the libraries it transitively depends on (which is everything in practice, but the layer check still catches accidental layering violations).
  2. Substitutability. Library users can pick a subset: a CI tool that just renders templates and never touches the engine can depend on guisu-template alone.

See also

Three-State Model

The engine keeps three views of every file under management, plus a persistent record of what was last applied. Comparing the four is what makes conflict detection and safe interactive resolution possible.

The four stores

The engine keeps four views of every file under management. The arrows below describe how data flows from one store to the next during an apply.

SOURCE                 TARGET                  DESTINATION            PERSISTENT
(filesystem)           (in memory)             (filesystem)           (redb)

.bashrc.j2     ───►    .bashrc (rendered) ──►  ~/.bashrc       ◄──►  blake3(.bashrc)
key.txt.age    ───►    key.txt (decrypted)──►  ~/key.txt      ◄──►  blake3(key.txt)
                     decrypt + render        write + chmod          record

Source     →  Target     : decrypt .age, then render .j2
Target     →  Destination: write to disk, apply mode
Target     ↔  Persistent : hash target, store in db
Target     ↔  Destination: three-way compare to detect Synced/Modified/Conflict

The bold arrows show the writes the apply loop performs. The plain arrows are reads that drive the comparison.

StoreWhere it livesMutable?Notes
SourceFiles in the source repository (filesystem)Read-only during applyFilenames encode extensions (.j2, .age, .j2.age); file mode bits come from metadata().mode().
TargetRendered, decrypted content (in memory)Recomputed on demandAlways the desired post-apply state for a given source.
DestinationThe actual files on the user’s machine (filesystem)Read + WriteWhere the user’s dotfiles actually live.
Persistentredb database at <source>/.guisu-state.dbWritten after a successful applyContent hash + mode of the last applied target.

Status types

For each file under management, the engine computes a status by comparing target, destination, and database:

TargetDestinationDatabaseStatusDefault action
AAASyncedSkip
ABAModified (by you)Overwrite
AABModified (in source)Apply
ABCConflictPrompt (--interactive) or overwrite
AAddedCreate
BBRemovedDelete
BAModified + RemovedConflict

Entry types

crates/engine/src/entry.rs defines three enums and a struct:

#![allow(unused)]
fn main() {
pub enum SourceEntry {
    File { source_path, target_path, attributes },
    Directory { source_path, target_path, attributes },
    Symlink { source_path, target_path, link_target },
}

pub enum TargetEntry {
    File { path, content: Vec<u8>, mode: Option<u32> },
    Directory { path, mode: Option<u32> },
    Symlink { path, target: PathBuf },
    Remove { path },
}

pub struct DestEntry {
    pub path: RelPath,
    pub kind: EntryKind,
    pub content: Option<Vec<u8>>,
    pub mode: Option<u32>,
    pub link_target: Option<PathBuf>,
}

pub enum EntryKind {
    File,
    Directory,
    Symlink,
    Missing,
}
}

SourceEntry and TargetEntry differ slightly because the source carries parsed attributes while the target carries concrete content.

Destinations may also receive a remove directive from Metadata::remove (declared in .guisu/state.toml); see User Guide — File Attributes. The apply step processes removes in a separate pre-pass before applying targets.

File attributes

FileAttributes in crates/engine/src/attr.rs is a plain struct:

#![allow(unused)]
fn main() {
pub struct FileAttributes {
    pub is_template: bool,    // .j2 extension
    pub is_encrypted: bool,   // .age extension
    pub mode: Option<u32>,     // source file's metadata().mode()
}
}

is_template and is_encrypted are decoded from the filename extension at read time. mode is read from the source file’s metadata().mode() and propagated to the destination by apply. The permission-related bitflags (private_ / readonly_ / executable_ / dot_ / exact_) and the entry-type prefixes (modify_ / remove_ / symlink_) are no longer recognized. See User Guide — File Attributes.

See also

Data Flow

This page traces the major commands through the layers. Each flow is the same shape: parse args → load config → load identities → read source → build target → compare with destination and database → resolve conflicts → write → update database.

guisu apply

The core command. Materialises source into destination. The flow is:

  1. Parse CLI args--interactive, --dry-run, --include, --exclude, source/destination overrides.
  2. Load .guisu.toml + any platform-specific variable files; merge them.
  3. Load age identities from the configured identity files (age keys or SSH keys).
  4. Build the template engine and a context populated with system info, guisu metadata, and user variables.
  5. Read SourceState — walk the source directory in parallel via rayon; parse file attributes from each filename; build SourceEntry objects.
  6. Build TargetState — for each source entry, decrypt .age files and render .j2 templates, again in parallel.
  7. Open the redb database at <source>/.guisu-state.db.
  8. For each entry in the target state (sequential, so writes are deterministic):
    1. Read the corresponding DestinationState entry (the actual file on disk).
    2. Load the database entry (the last applied hash + mode).
    3. Three-way compare the three to compute a FileStatus.
    4. Resolve the status:
      • Synced — skip.
      • Added / Modified — write the target content with the target mode.
      • Conflict — if --interactive, open the TUI; otherwise overwrite.
      • User can also Quit from the TUI, which aborts the entire apply.
    5. Update the database with the new hash and mode.
  9. Show stats — counts of added / modified / skipped / errored entries.

Steps 5 and 6 use rayon for parallel processing. Steps 8 and 9 are sequential so writes happen in a deterministic order and a mid-apply crash leaves the destination in a recoverable state.

guisu init

  1. Parse the target — a local path, a GitHub username, or owner/repo.
  2. Determine the source directory (default ~/.local/share/guisu).
  3. If the source directory already exists, error out.
  4. Clone the repository (in-process via the git2 crate).
  5. Run guisu apply in interactive mode so the user can review the changes before they land.

guisu add

  1. Resolve the path: expand ~, make it absolute, verify it exists.
  2. Compute the target path: strip the $HOME prefix.
  3. If --encrypt was passed, encrypt the content and append .age as the suffix. --template instead appends .j2. Otherwise the file is used as-is.
  4. Copy the (possibly transformed) file to the source directory, preserving metadata.
  5. Run git add on the resulting source path.

--private and --executable do not change the file’s location, only the attributes Guisa applies on the next apply.

guisu update

  1. Open the source repo via git2.
  2. git fetch origin.
  3. If --rebase, run git rebase; otherwise git merge.
  4. If there are conflicts, error out (the user must resolve them manually).
  5. Run guisu apply.

guisu edit

  1. Map the destination path back to the source path (look for .j2 / .age suffixes).
  2. If the source is an .age file, decrypt to a temp file (mode 0600).
  3. Open the source (or temp) file in $EDITOR and wait for it to exit.
  4. If the content’s hash changed, and the source is .age, re-encrypt and replace the source. Otherwise just replace the source.
  5. Delete the temp file.

The temp file lives on the same filesystem as the source, with mode 0600, and is unlink()-ed when the editor exits. Secure erasure (overwrite before unlink) is on the roadmap but not implemented.

Parallel processing

The engine uses rayon for two passes:

PassOperationWhat parallelises
Read sourceSourceState::read(root)One task per file system entry: read bytes, parse attributes, build SourceEntry.
Build targetTargetState::from_source(source, processor, context)One task per source entry: decrypt (if .age), render template (if .j2), build TargetEntry.

Sequential steps (write, update db, compare) stay single-threaded to keep the on-disk state coherent and the diff output stable.

See also

Crates

Detailed per-crate reference. The seven crates form a strict DAG; see Architecture for the diagram. Module paths below are crates/<name>/src/....

guisu-core

Foundation types shared by every other crate. Zero non-std dependencies.

ModuleTypes
path.rsAbsPath, RelPath — newtype wrappers around PathBuf that cannot be mixed at compile time.
platform.rsPlatform { os, arch } and the CURRENT_PLATFORM constant.
traits.rsShared traits: AsAbsPath, AsRelPath.
error.rsError enum with the variants every crate needs.
#![allow(unused)]
fn main() {
let p = AbsPath::new("/home/user/.config/guisu")?;
let r = p.join(RelPath::new("dotfiles")?);
}

guisu-crypto

age encryption, file and inline. Built on the age crate.

ModulePublic API
age.rsdecrypt_file_content, encrypt_file_content, decrypt_inline, encrypt_inline.
identity.rsload_identities(path, is_ssh) — read age or SSH identity files.
recipient.rsparse_recipient(string) and derive_recipients(identities) — used during encryption.

Encryption writes to all configured recipients; decryption tries each identity in order until one works.

guisu-vault

Secret provider abstraction over password-manager CLIs.

#![allow(unused)]
fn main() {
pub trait SecretProvider: Send + Sync {
    fn name(&self) -> &str;
    fn is_available(&self) -> bool;
    fn execute(&self, args: &[&str]) -> Result<serde_json::Value>;
    fn help(&self) -> &str;
}
}
FileProvider
bw.rsBwCli (Bitwarden CLI) and RbwCli (unofficial Rust Bitwarden CLI).
bws.rsBwsCli (Bitwarden Secrets).

The cache lives in guisu-template (per-apply), not here.

guisu-template

minijinja environment with a curated function library.

ModuleWhat it contains
engine.rsTemplateEngine::new() and the two with_* constructors. add_function / add_filter calls register every function.
context.rsTemplateContext — the typed bag of variables injected into every render.
functions/One file per category: system.rs, vault.rs, strings.rs, data.rs, crypto.rs, files.rs.
info.rsHelper for guisu info to summarise the template engine state.
#![allow(unused)]
fn main() {
let engine = TemplateEngine::with_identities_and_template_dir(
    identities,
    Some(template_dir),
);
let rendered = engine.render_str(template, &context)?;
}

guisu-config

Loads and merges .guisu.toml plus platform-specific variable files.

ModuleWhat it contains
config.rsThe Config struct and sub-configs (GeneralConfig, AgeConfig, BitwardenConfig, UiConfig, IgnoreConfig, EditConfig).
dirs.rsresolve_dirs — applies [general] src_dir / dst_dir to the runtime context.
ignores.rsIgnoresConfig and the loader for per-platform ignore files.
patterns.rsIgnoreMatcher — gitignore-style pattern compilation.
variables.rsPer-platform variable loading (.guisu/variables/{darwin,linux,windows}/*.toml).
#![allow(unused)]
fn main() {
let config = Config::load_from_source(source_dir)?;
let patterns = config.platform_ignore_patterns();
}

guisu-engine

The three-state model and the apply loop. The largest crate by line count.

ModuleWhat it contains
state.rsSourceState, TargetState, DestinationState, PersistentState trait + RedbPersistentState, EntryState, HookState.
entry.rsSourceEntry, TargetEntry, DestEntry, EntryKind.
attr.rsFileAttributes (plain struct: is_template, is_encrypted, mode).
content.rsThe raw byte pipeline.
processor.rsContentProcessor<D, R> — generic decrypt + render pipeline.
database.rsredb table definitions.
hash.rsBLAKE3 helpers.
system.rsPlatform-specific destination state reads.
validator.rsCross-state validation.
git.rsIn-process git operations (init, fetch, merge) using git2.
hooks/Pre/post/once/onchange hook discovery and execution.
adapters/Alternative implementations (e.g. for tests).

guisu-cli

Binary + library. The binary entry point is src/main.rs, which delegates to guisu::run(cli).

ModuleWhat it contains
lib.rsCli (clap derive) and Commands enum.
cmd/One file per subcommand: add.rs, age.rs, apply.rs, cat.rs, diff.rs, edit.rs, hooks.rs, ignored.rs, info.rs, init.rs, status.rs, templates.rs, update.rs, variables.rs.
command.rsThe Command trait implemented by every subcommand.
common.rsRuntimeContext — shared state (config, paths, etc.) passed to every command.
conflict.rsThe interactive conflict TUI.
ui/Reusable TUI widgets.
stats.rsApplyStats — counted output of an apply.
#![allow(unused)]
fn main() {
impl Command for ApplyCommand {
    type Output = ApplyStats;
    fn execute(&self, ctx: &RuntimeContext) -> Result<ApplyStats> { ... }
}
}

Public surface stability

The guisu-cli binary’s command-line interface is stable. The library crates (guisu-core, guisu-crypto, guisu-vault, guisu-template, guisu-config, guisu-engine) are not yet API-stable; expect breaking changes before v1.0. See the Roadmap for the stabilisation timeline.

See also

Error Handling

Guisu uses a two-tier error strategy, mirrored by AGENTS.md “Rules”:

  1. Libraries (guisu-core, guisu-crypto, guisu-engine, …) — typed errors via thiserror. The error enum is pub and variants are matchable. Errors are converted to anyhow::Error at the boundary.
  2. CLI (guisu-cli) — anyhow::Result at the boundary. Library errors are converted via ? and .context(...) is added at the call site.

Library error example

#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("failed to read source file {path}: {source}")]
    ReadSource {
        path: String,
        #[source]
        source: std::io::Error,
    },

    #[error("template error at {location}: {message}")]
    Render { location: String, message: String },

    #[error("decrypt failed for {path}: {source}")]
    Decrypt {
        path: String,
        #[source]
        source: guisu_crypto::Error,
    },
}
}

Key conventions:

  • One error enum per crate, named simply Error and re-exported as crate::Error.
  • #[source] chains the underlying cause so it shows up in the Display output and std::error::Error::source() chain.
  • #[from] is used sparingly — most conversions go through explicit map_err calls so the user-visible error stays short.

CLI usage

#![allow(unused)]
fn main() {
use anyhow::Context;

fn run() -> anyhow::Result<()> {
    let state = engine::read_source(&path)
        .context("failed to read source state")?;
    let target = engine::build_target(&state, &processor, &context)
        .context("failed to build target state")?;
    engine::apply(&target, &dest, &apply_opts)
        .context("apply failed")?;
    Ok(())
}
}

The .context(...) calls add a one-line breadcrumb that miette renders as part of the error chain. crates/cli/src/main.rs configures miette to print the full chain in a coloured, code-aware format.

Logging

tracing is used for structured logging. Levels: ERROR, WARN, INFO, DEBUG, TRACE. RUST_LOG=info is the default for the CLI; raise it with RUST_LOG=guisu_engine=debug for a single crate.

#![allow(unused)]
fn main() {
use tracing::{info, warn, error, debug, instrument};

#[instrument(skip(content))]
fn process_file(path: &Path, content: &[u8]) -> Result<()> {
    debug!(path = %path.display(), size = content.len(), "processing file");
    // ...
    info!(path = %path.display(), "file processed successfully");
    Ok(())
}
}

When to use unwrap

Almost never in library code. The two acceptable places are:

  1. In tests (#[cfg(test)] mod tests) where a panic is a clear test failure.
  2. In build.rs or other build-time code where a panic is acceptable because the build itself is the only thing that can fail.

Application code (guisu-cli) uses anyhow::Context and lets miette display the error. Library code uses ? and typed errors.

See also

  • Contributing — the rules for adding new errors.
  • AGENTS.md — the project-level rule “No bare unwrap() — use ? with anyhow”.

Contributing

Thanks for your interest in contributing. Guisu is pre-1.0; expect breaking API changes in the library crates.

Documentation

Warning

Update docs in the same PR Every PR that adds or changes a user-facing command, flag, config key, file-attribute convention, template function, or hook mode must update the relevant page under docs/src/{user-guide,reference}/ in the same PR. The PR template (below) has a matching checklist.

Checklist before opening a PR:

The site is English-only. A Chinese translation is not in scope; if one is added later, it is a separate effort and tracked elsewhere.

Development setup

git clone https://github.com/YvanY0/guisu.git
cd guisu
cargo build
cargo test --workspace
cargo clippy --workspace -- -D warnings
cargo fmt -- --check

Recommended tools:

  • mise — Rust toolchain + project tasks (see rust-toolchain.toml).
  • pre-commit — see .pre-commit-config.yaml. Install once with pre-commit install.
  • cargo-nextest — faster test runner (cargo nextest run).

Pull request process

  1. Branch from main. Use a short descriptive prefix: feat/..., fix/..., docs/..., refactor/....
  2. Commit with -s and -S (DCO + GPG/SSH signature). The repo enforces both. See AGENTS.md “Commit rules”.
  3. Run the build & verify commands above. They must all pass.
  4. Open a PR with a clear title and a short description of the change. Reference any related issues.
  5. Address review feedback. Squash-merge is preferred for a linear history.

PR template

## Description
Brief description of changes.

## Motivation
Why is this change needed?

## Changes
- List of changes
- Another change

## Documentation
- [ ] Updated the relevant docs page (which one: ...)
- [ ] No docs update needed (reason: ...)

## Testing
How was this tested?

## Checklist
- [ ] Tests added/updated
- [ ] `cargo fmt -- --check` passes
- [ ] `cargo clippy --workspace -- -D warnings` passes
- [ ] `cargo test --workspace` passes
- [ ] Commit signed (`-s -S`)

Architecture guidelines

  • Follow the existing layered architecture — see Architecture. Lower layers never depend on higher layers.
  • Use newtype paths (AbsPath, RelPath), not raw PathBuf.
  • Use ? with .context(...) in the CLI, not bare unwrap(). Library errors use thiserror; see Error Handling.
  • For new template functions, follow the existing per-category layout in crates/template/src/functions/. One file per category; one function per pub fn with a doctest.
  • For new vault providers, implement SecretProvider in crates/vault/src/. See the add-vault-provider skill for the full checklist.

Adding a new subcommand

  1. Create crates/cli/src/cmd/<name>.rs with a #[derive(Args)] struct and a Command impl.
  2. Add a variant to Commands in crates/cli/src/lib.rs with a docstring.
  3. Add a row to Reference — Commands.
  4. Add tests under crates/cli/src/cmd/<name>_test.rs (or inline with #[cfg(test)]).

See also