diff --git a/.agents/skills/managing-chezmoi/SKILL.md b/.agents/skills/managing-chezmoi/SKILL.md index b59682f2..03bfc972 100644 --- a/.agents/skills/managing-chezmoi/SKILL.md +++ b/.agents/skills/managing-chezmoi/SKILL.md @@ -18,7 +18,7 @@ This repo is edited from inside a nono sandbox. Treat chezmoi source files as th ## Default Workflow -1. **Confirm source ownership**: before editing a file under `~`, use `chezmoi source-path ` or inspect whether the source uses `dot_*`, `private_dot_*`, `dot_config/`, `private_dot_config/`, or a template source +1. **Confirm source ownership**: before editing a file under `~`, use `chezmoi source-path ` or inspect whether the source uses `dot_*`, `private_dot_*`, `dot_config/`, `private_dot_config/`, or a template source. When `source-path` reports "not managed", search the `run_*` scripts at the repo root for the target path before treating the file as unmanaged: some scripts own keys inside target config files, and the next apply overwrites manual edits to those keys 2. **Inspect state**: use `chezmoi status` and `chezmoi diff` for managed targets; use `git diff` for repo-only files that chezmoi does not manage as targets 3. **Edit source directly**: modify files in this repo (the source state) 4. **Validate safely**: run `chezmoi diff` and `chezmoi apply --dry-run -v` for managed targets; stop at source review and git diff for unmanaged repo files diff --git a/.agents/skills/managing-chezmoi/references/commands-and-workflows.md b/.agents/skills/managing-chezmoi/references/commands-and-workflows.md index 4959a106..63c56b00 100644 --- a/.agents/skills/managing-chezmoi/references/commands-and-workflows.md +++ b/.agents/skills/managing-chezmoi/references/commands-and-workflows.md @@ -47,7 +47,9 @@ This file is a *practical* quick reference for `chezmoi` operations in a dotfile ### 7) Repo-only files - Some files in this repo are not managed as chezmoi targets. -- If `chezmoi source-path ` reports "not managed", validate with source review and `git diff` instead of trying to apply. +- `chezmoi source-path ` only knows targets that map to a source file. A target that a `run_*` script writes or edits also reports "not managed". +- Before treating a target as unmanaged, search the `run_*` scripts at the repo root for its path, for example `grep -l 'worktrunk' run_*`. If a script owns the setting, change the value in the script, not in the target file: the script rewrites the target on every apply. +- For repo files that no target or script uses, validate with source review and `git diff` instead of trying to apply. ## Day-to-day commands diff --git a/dot_agents/shell/nono.zsh.tmpl b/dot_agents/shell/nono.zsh.tmpl index 1d0a980f..a6bb83c3 100644 --- a/dot_agents/shell/nono.zsh.tmpl +++ b/dot_agents/shell/nono.zsh.tmpl @@ -50,7 +50,27 @@ function _nono_agent_run() { local -a git_common_dir_args=() local git_common_dir if git_common_dir="$(command git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"; then - git_common_dir_args=(--allow "$git_common_dir") + # The folder holding .git is the main checkout, or in the bare worktree + # layout (git wtclone) the container of every worktree. Never widen to + # $HOME or / when a repository lives there. + if [[ "${git_common_dir:t}" == .git && "${git_common_dir:h}" != "$HOME" && "${git_common_dir:h}" != / ]]; then + git_common_dir_args=(--allow "${git_common_dir:h}") + else + git_common_dir_args=(--allow "$git_common_dir") + fi + + # A classic (non-bare) clone keeps its worktrees in a central folder keyed + # by the main checkout path, matching the worktree-path template that + # run_after_ensure-worktrunk-config.sh writes for worktrunk. + if [[ "$(command git --git-dir="$git_common_dir" config --bool core.bare 2>/dev/null)" != true ]]; then + local main_checkout + main_checkout="$(command git worktree list --porcelain 2>/dev/null | command sed -n '1s/^worktree //p')" + if [[ -n "$main_checkout" ]]; then + local worktrees_dir="$HOME/.local/share/worktrees/${${main_checkout//[\/\\]/-}//./-}" + command mkdir -p "$worktrees_dir" && + git_common_dir_args+=(--allow "$worktrees_dir") + fi + fi fi local -a acp_args=() diff --git a/private_dot_local/bin/executable_git-wtclone b/private_dot_local/bin/executable_git-wtclone new file mode 100644 index 00000000..7b7b9687 --- /dev/null +++ b/private_dot_local/bin/executable_git-wtclone @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Clone a repository into the bare worktree layout: +# /.git bare repository +# / worktree of the default branch, submodules initialized +# Arguments match git clone: git wtclone [] [] + +die() { + echo "git-wtclone: $*" >&2 + exit 1 +} + +usage() { + echo "usage: git wtclone [] []" >&2 + exit 2 +} + +# git clone options that take their value as a separate argument. +takes_value() { + case "$1" in + -o | --origin | -b | --branch | -u | --upload-pack | --reference | --reference-if-able | \ + --depth | --shallow-since | --shallow-exclude | -c | --config | --server-option | \ + -j | --jobs | --filter | --bundle-uri | --template | --ref-format | --revision) + return 0 + ;; + esac + return 1 +} + +clone_opts=() +positional=() +single_branch=false +while (( $# > 0 )); do + case "$1" in + --) + shift + positional+=("$@") + break + ;; + -h | --help) + usage + ;; + --bare | --mirror | --separate-git-dir | --separate-git-dir=*) + die "$1 conflicts with the worktree layout" + ;; + --recurse-submodules | --recurse-submodules=* | --recursive) + # Submodules are always initialized in the worktree after cloning. + shift + ;; + --single-branch) + single_branch=true + clone_opts+=("$1") + shift + ;; + -*) + if takes_value "$1"; then + (( $# >= 2 )) || usage + clone_opts+=("$1" "$2") + shift 2 + else + clone_opts+=("$1") + shift + fi + ;; + *) + positional+=("$1") + shift + ;; + esac +done + +(( ${#positional[@]} >= 1 && ${#positional[@]} <= 2 )) || usage +repo="${positional[0]}" +dir="${positional[1]:-}" + +if [[ -z "$dir" ]]; then + # Same naming rule as git clone: last path component without .git. + dir="${repo%/}" + dir="${dir%/.git}" + dir="${dir%.git}" + dir="${dir##*/}" + dir="${dir##*:}" + [[ -n "$dir" ]] || die "cannot derive a directory name from '$repo'; pass " +fi + +if [[ -e "$dir" && -n "$(ls -A "$dir")" ]]; then + die "destination path '$dir' already exists and is not an empty directory" +fi + +git clone --bare ${clone_opts[@]+"${clone_opts[@]}"} -- "$repo" "$dir/.git" + +git_dir="$(cd "$dir/.git" && pwd -P)" +container="${git_dir%/.git}" + +g() { + git --git-dir="$git_dir" "$@" +} + +remote="$(g remote | head -n 1)" +branch="$(g symbolic-ref --short HEAD)" + +# A bare clone maps remote branches straight onto local branches and sets no +# fetch refspec. Track them as remote-tracking branches instead, like a normal +# clone, and keep only the checked-out branch local. +if [[ "$single_branch" == true ]]; then + g config "remote.$remote.fetch" "+refs/heads/$branch:refs/remotes/$remote/$branch" +else + g config "remote.$remote.fetch" "+refs/heads/*:refs/remotes/$remote/*" +fi +g fetch --quiet "$remote" +g remote set-head "$remote" --auto >/dev/null 2>&1 || + echo "git-wtclone: could not set $remote/HEAD; run 'git remote set-head $remote --auto' later" >&2 + +g for-each-ref --format='%(refname)' refs/heads/ | while IFS= read -r ref; do + [[ "$ref" == "refs/heads/$branch" ]] || g update-ref -d "$ref" +done +g branch --quiet --set-upstream-to="$remote/$branch" "$branch" + +# Folder name follows worktrunk's sanitize filter: / and \ become -. +worktree="$container/${branch//[\/\\]/-}" +g worktree add --quiet "$worktree" "$branch" +git -C "$worktree" submodule update --init --recursive --quiet + +echo "Cloned into $worktree" diff --git a/private_dot_local/bin/executable_git-wtmigrate b/private_dot_local/bin/executable_git-wtmigrate new file mode 100644 index 00000000..67e750ac --- /dev/null +++ b/private_dot_local/bin/executable_git-wtmigrate @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Convert a regular clone into the bare worktree layout used by git wtclone: +# /.git the former .git directory, now a bare repository +# / the former main checkout, now a linked worktree +# Linked worktrees on a branch move to / as well. Uncommitted, +# staged, untracked, and ignored files stay in place. +# Usage: git wtmigrate [] (default: the repository of the current directory) + +die() { + echo "git-wtmigrate: $*" >&2 + exit 1 +} + +warn() { + echo "git-wtmigrate: $*" >&2 +} + +# Folder name follows worktrunk's sanitize filter: / and \ become -. +sanitize() { + local name="$1" + echo "${name//[\/\\]/-}" +} + +(( $# <= 1 )) || die "usage: git wtmigrate []" +target="${1:-.}" + +common="$(git -C "$target" rev-parse --path-format=absolute --git-common-dir)" || + die "'$target' is not inside a git repository" +[[ "$(basename "$common")" == .git && -d "$common" ]] || + die "expected the repository data in a .git directory, found $common" +[[ "$(git --git-dir="$common" config --bool --get core.bare || echo false)" == false ]] || + die "$(dirname "$common") already uses the bare layout" +[[ -z "$(git --git-dir="$common" config --get core.worktree || true)" ]] || + die "core.worktree is set; this layout is not supported" +[[ ! -d "$common/modules" ]] || + die "repositories with initialized submodules are not supported" + +for state in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG rebase-merge rebase-apply sequencer; do + [[ ! -e "$common/$state" ]] || + die "an operation is in progress ($state); finish or abort it first" +done + +repo="$(dirname "$common")" +branch="$(git --git-dir="$common" symbolic-ref --quiet --short HEAD)" || + die "the main checkout has a detached HEAD; check out a branch first" +main_dir="$(sanitize "$branch")" + +# Collect linked worktrees that sit on a branch; others stay where they are. +linked_paths=() +linked_dests=() +used_names="$main_dir" +wt_path="" +wt_branch="" +wt_skip=false +flush_worktree() { + if [[ -n "$wt_path" && "$wt_path" != "$repo" ]]; then + if [[ "$wt_path" == "$repo"/* ]]; then + die "worktree $wt_path is nested inside the main checkout; move it out first" + fi + if [[ "$wt_skip" == true || -z "$wt_branch" ]]; then + warn "leaving $wt_path in place (detached, locked, or missing)" + else + local name + name="$(sanitize "$wt_branch")" + if grep -Fxq -- "$name" <<<"$used_names"; then + die "two worktrees map to the folder name '$name'" + fi + used_names+=$'\n'"$name" + linked_paths+=("$wt_path") + linked_dests+=("$repo/$name") + fi + fi + wt_path="" + wt_branch="" + wt_skip=false +} +while IFS= read -r line; do + case "$line" in + "worktree "*) wt_path="${line#worktree }" ;; + "branch refs/heads/"*) wt_branch="${line#branch refs/heads/}" ;; + locked* | prunable*) wt_skip=true ;; + "") flush_worktree ;; + esac +done < <(git --git-dir="$common" worktree list --porcelain) +flush_worktree + +[[ "$main_dir" != .git ]] || die "a branch named '.git' cannot become a folder" + +parent="$(dirname "$repo")" +tmp="$(mktemp -d "$parent/.$(basename "$repo").wtmigrate.XXXXXX")" +trap 'warn "failed partway; pieces of the repository may be in $repo and $tmp"' ERR + +# Move the main checkout aside, then put its .git back as the bare repository. +mv "$repo" "$tmp/checkout" +mkdir "$repo" +mv "$tmp/checkout/.git" "$common" +git --git-dir="$common" config core.bare true + +# Register the checkout as a linked worktree. Keep its index (staged changes) +# and HEAD reflog, then swap the real files in under the new .git file. +[[ ! -f "$common/index" ]] || mv "$common/index" "$tmp/index" +git --git-dir="$common" worktree add --quiet --no-checkout "$repo/$main_dir" "$branch" +admin="$(git -C "$repo/$main_dir" rev-parse --path-format=absolute --git-dir)" +[[ ! -f "$tmp/index" ]] || mv "$tmp/index" "$admin/index" +if [[ -f "$common/logs/HEAD" ]]; then + mkdir -p "$admin/logs" + mv "$common/logs/HEAD" "$admin/logs/HEAD" +fi +mv "$repo/$main_dir/.git" "$tmp/checkout/.git" +rmdir "$repo/$main_dir" +mv "$tmp/checkout" "$repo/$main_dir" +rmdir "$tmp" +trap - ERR + +git -C "$repo/$main_dir" update-index -q --refresh >/dev/null || true + +for (( i = 0; i < ${#linked_paths[@]}; i++ )); do + src="${linked_paths[$i]}" + dest="${linked_dests[$i]}" + if [[ -e "$dest" ]]; then + warn "leaving $src in place; $dest already exists" + elif ! git --git-dir="$common" worktree move "$src" "$dest"; then + warn "could not move $src to $dest" + fi +done + +echo "Migrated $repo; the former main checkout is now $repo/$main_dir" +echo "Shells inside the old checkout should cd to the new path." diff --git a/private_dot_local/share/man/man1/git-wtclone.1 b/private_dot_local/share/man/man1/git-wtclone.1 new file mode 100644 index 00000000..adbab0a4 --- /dev/null +++ b/private_dot_local/share/man/man1/git-wtclone.1 @@ -0,0 +1,138 @@ +.TH GIT-WTCLONE 1 2026-10-21 dotfiles "User Commands" +.SH NAME +git-wtclone \- clone a repository into the bare worktree layout +.SH SYNOPSIS +.B git wtclone +.RI [ "clone options" ] +.I repo +.RI [ dir ] +.SH DESCRIPTION +Clones +.I repo +the way +.BR git\-clone (1) +does, but lays it out so every worktree sits in one folder: +.PP +.RS +.nf +/.git bare repository (git data only) +/ worktree of the checked-out branch +.fi +.RE +.PP +Worktrees that +.BR wt (1) +creates later land next to it as +.IR / . +The nono agent launchers grant the whole +.I +folder, so one agent session can work across all worktrees. +.PP +After cloning, +.B git wtclone +sets up the bare repository to behave like a normal clone: +.IP \(bu 2 +remote branches are fetched as remote-tracking branches +.RI ( origin/* ), +and +.I origin/HEAD +points at the remote's default branch; +.IP \(bu 2 +only the checked-out branch exists as a local branch, tracking its remote +branch; +.IP \(bu 2 +submodules are initialized recursively in the new worktree. +.SH ARGUMENTS +.TP +.I repo +The repository to clone, in any form +.BR git\-clone (1) +accepts. +.TP +.I dir +The container folder. Defaults to the last path component of +.I repo +without +.IR .git , +created in the current folder. It must not exist or must be empty. +.SH OPTIONS +Options are passed on to +.BR git\-clone (1), +with these exceptions: +.TP +.BR \-\-bare ", " \-\-mirror ", " \-\-separate\-git\-dir +Rejected; they conflict with the layout. +.TP +.BR \-\-recurse\-submodules ", " \-\-recursive +Ignored; submodules are always initialized. +.TP +.BI \-b " branch" +Checks out +.I branch +instead of the remote's default branch. The worktree folder is named after it. +.TP +.B \-\-single\-branch +Fetches only the checked-out branch, now and on later fetches. +.SH FOLDER NAMES +Worktree folders use the branch name with +.B / +and +.B \e +replaced by +.BR \- , +matching the +.B sanitize +filter of +.BR wt (1). +For example, branch +.I feature/login +becomes the folder +.IR feature\-login . +.SH CLASSIC REPOSITORIES +Repositories that must stay a regular clone, because another tool decides +where they live, keep working. For them, +.BR wt (1) +creates worktrees in a central folder instead: +.PP +.RS +.nf +~/.local/share/worktrees// +.fi +.RE +.PP +.I +is the path of the main checkout with +.BR / , +.BR \e , +and +.B . +replaced by +.BR \- , +for example +.I \-Users\-me\-\-local\-share\-chezmoi +for +.IR /Users/me/.local/share/chezmoi . +The nono agent launchers grant the main checkout and that folder. +.SH EXAMPLES +.TP +.B git wtclone git@github.com:acme/foo.git +Creates +.I ./foo/.git +and +.I ./foo/main +(if +.I main +is the default branch). +.TP +.B git wtclone \-\-depth 1 git@github.com:acme/foo.git ~/projects/acme/foo +Shallow clone into +.IR ~/projects/acme/foo . +.TP +.B wt switch \-\-create feature/login +Run inside the container or a worktree afterwards; creates +.IR foo/feature\-login . +.SH SEE ALSO +.BR git\-wtmigrate (1), +.BR git\-clone (1), +.BR git\-worktree (1), +.BR wt (1) diff --git a/private_dot_local/share/man/man1/git-wtmigrate.1 b/private_dot_local/share/man/man1/git-wtmigrate.1 new file mode 100644 index 00000000..0f6e30e3 --- /dev/null +++ b/private_dot_local/share/man/man1/git-wtmigrate.1 @@ -0,0 +1,97 @@ +.TH GIT-WTMIGRATE 1 2026-10-21 dotfiles "User Commands" +.SH NAME +git-wtmigrate \- convert an existing clone into the bare worktree layout +.SH SYNOPSIS +.B git wtmigrate +.RI [ path ] +.SH DESCRIPTION +Converts a regular clone into the layout that +.BR git\-wtclone (1) +creates. For a clone at +.I foo +whose main checkout is on branch +.IR main : +.PP +.RS +.nf +before after +foo/ foo/.git (bare repository) +foo/.git/ foo/main/ (former main checkout) +foo-feature-x/ (worktree) foo/feature-x/ (moved worktree) +.fi +.RE +.PP +Every file in the main checkout stays as it was, including uncommitted, +staged, untracked, and ignored files. The former +.I .git +folder becomes the bare repository in place, so worktrees that are not +moved keep working. +.PP +Linked worktrees that are on a branch move to +.IR foo/ . +Detached or locked worktrees, and worktrees whose target folder already +exists, stay where they are, with a warning. +.PP +Shells that were inside the old main checkout keep a stale path; run +.B cd +to the new folder afterwards. +.SH ARGUMENTS +.TP +.I path +Any folder inside the repository to convert, including a linked worktree. +Defaults to the current folder. +.SH REFUSED CASES +.B git wtmigrate +stops without changing anything when: +.IP \(bu 2 +the repository already uses the bare layout; +.IP \(bu 2 +the main checkout has a detached HEAD; +.IP \(bu 2 +a merge, rebase, cherry-pick, revert, or bisect is in progress; +.IP \(bu 2 +the repository has initialized submodules; +.IP \(bu 2 +.I core.worktree +is set; +.IP \(bu 2 +a linked worktree sits inside the main checkout; +.IP \(bu 2 +two worktrees would get the same folder name. +.SH FAILURE +The checkout is first moved to a temporary folder named +.I ..wtmigrate.XXXXXX +next to the repository. If a step fails after that, the error names both +locations, so the pieces can be put back by hand. +.SH NOTES +The command moves the repository folder, so it needs write access to the +folder that contains it. Run it outside agent sandboxes. +.PP +Repositories that cannot move do not need this command; see CLASSIC +REPOSITORIES in +.BR git\-wtclone (1). +.PP +Folder names follow the same rule as +.BR git\-wtclone (1): +.B / +and +.B \e +in branch names become +.BR \- . +.SH EXAMPLES +.TP +.B git wtmigrate ~/projects/acme/foo +Converts +.I foo +from anywhere. +.TP +.B git wtmigrate && cd ~/projects/acme/foo/main +Run inside +.IR foo ; +converts it, then moves the shell into the former main checkout (here on +branch +.IR main ). +.SH SEE ALSO +.BR git\-wtclone (1), +.BR git\-worktree (1), +.BR wt (1) diff --git a/run_after_ensure-worktrunk-config.sh b/run_after_ensure-worktrunk-config.sh index 5b39d102..c2572af2 100644 --- a/run_after_ensure-worktrunk-config.sh +++ b/run_after_ensure-worktrunk-config.sh @@ -11,8 +11,12 @@ set -euo pipefail CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/worktrunk" CONFIG_FILE="$CONFIG_DIR/config.toml" -# Sibling worktrees joined with a dash; nono cannot open directories with dots. -WORKTREE_PATH_LINE='worktree-path = "{{ repo_path }}/../{{ repo }}-{{ branch | sanitize }}"' +# Bare worktree layout (git wtclone / git wtmigrate): repo_path is /.git, +# so every worktree lands in / next to the bare repository. +# Classic clones whose location is fixed get a central folder instead, keyed by +# the checkout path with / and . replaced by -. The nono launcher in +# ~/.agents/shell/nono.zsh derives the same key, so keep both in sync. +WORKTREE_PATH_LINE="worktree-path = \"{% if repo_path is endingwith('/.git') %}{{ repo_path }}/../{{ branch | sanitize }}{% else %}~/.local/share/worktrees/{{ repo_path | sanitize | replace('.', '-') }}/{{ branch | sanitize }}{% endif %}\"" HOOK_NAME="agents-settings" LEGACY_HOOK_NAME="claude-settings"