herdr-worktreeinclude 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ All notable changes are published in the [GitHub Releases](https://github.com/serhii-chernenko/herdr-worktreeinclude/releases) generated from version tags.
4
+
5
+ ## 0.1.0
6
+
7
+ - Initial Herdr plugin: project-local worktrees, `.worktreeinclude` copying, and UI-first branch cleanup.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # herdr-worktreeinclude
2
+
3
+ A [Herdr](https://herdr.dev) plugin that keeps Herdr-created Git worktrees inside the project and restores selected ignored files into them.
4
+
5
+ ## What it does
6
+
7
+ Use Herdr's normal **New worktree** UI. After creation, the plugin automatically:
8
+
9
+ - moves the checkout from Herdr's global directory to `<project>/.herdr/worktrees/<branch>`;
10
+ - copies files selected by `.worktreeinclude` from the primary checkout;
11
+ - opens the relocated worktree as the active Herdr workspace.
12
+
13
+ When you remove a plugin-managed worktree through Herdr's normal UI, an unchanged branch (one already contained in the primary checkout's `HEAD`) is removed silently. For a branch with unique commits, Herdr shows an in-terminal overlay: press `d` to safely delete the local branch or Enter to keep it.
14
+
15
+ The plugin cannot change Herdr's built-in creation/removal dialogs. It uses lifecycle hooks immediately after those dialogs complete.
16
+
17
+ ## Requirements
18
+
19
+ - Herdr with plugin support (currently the Preview channel)
20
+ - Git
21
+ - Node.js 20 or later
22
+ - macOS or Linux
23
+
24
+ ## Install
25
+
26
+ Once the repository is public and tagged with the GitHub topic `herdr-plugin`:
27
+
28
+ ```bash
29
+ herdr plugin install serhii-chernenko/herdr-worktreeinclude
30
+ ```
31
+
32
+ To install a specific release:
33
+
34
+ ```bash
35
+ herdr plugin install serhii-chernenko/herdr-worktreeinclude --ref v0.1.0
36
+ ```
37
+
38
+ For local development, link it into the current Herdr session:
39
+
40
+ ```bash
41
+ herdr plugin link /path/to/herdr-worktreeinclude
42
+ ```
43
+
44
+ Plugins are registered per Herdr session. For a named session, include its name:
45
+
46
+ ```bash
47
+ herdr --session my-session plugin link /path/to/herdr-worktreeinclude
48
+ ```
49
+
50
+ ## Configure copied files
51
+
52
+ Create `.worktreeinclude` at the project root. It uses `.gitignore` pattern syntax, but a file is copied only when it is both selected by `.worktreeinclude` and ignored by Git.
53
+
54
+ ```gitignore
55
+ # .gitignore
56
+ .env
57
+ config/local/
58
+ ```
59
+
60
+ ```gitignore
61
+ # .worktreeinclude
62
+ .env
63
+ config/local/
64
+ ```
65
+
66
+ Tracked files are never copied. Existing destination files are not overwritten. Symlinks and file modes are preserved.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ npm install
72
+ npm run check
73
+ ```
74
+
75
+ After changing the manifest or scripts, relink the plugin in the target Herdr session:
76
+
77
+ ```bash
78
+ herdr plugin unlink serhii-chernenko.worktreeinclude
79
+ herdr plugin link "$(pwd)"
80
+ ```
81
+
82
+ ## Releases
83
+
84
+ The npm package and Herdr manifest share one version. Before publishing, update both `package.json` and `herdr-plugin.toml`, add release notes to `CHANGELOG.md`, then commit and push a matching tag:
85
+
86
+ ```bash
87
+ git tag v0.1.1
88
+ git push origin main v0.1.1
89
+ ```
90
+
91
+ The tag workflow verifies the version, publishes to npm through npm Trusted Publishing, and creates GitHub Release notes automatically. After claiming the package name with an initial manual publish, configure npm's Trusted Publisher for GitHub repository `serhii-chernenko/herdr-worktreeinclude` and workflow filename `publish.yml`. Subsequent matching version tags publish without a stored npm token.
@@ -0,0 +1,26 @@
1
+ id = "serhii-chernenko.worktreeinclude"
2
+ name = "Project-local Worktrees"
3
+ version = "0.1.0"
4
+ min_herdr_version = "0.7.0"
5
+ description = "Creates project-local Git worktrees and restores ignored files selected by .worktreeinclude."
6
+ platforms = ["macos", "linux"]
7
+
8
+ [[events]]
9
+ on = "worktree.created"
10
+ command = ["bash", "scripts/on-worktree-created.sh"]
11
+
12
+ [[events]]
13
+ on = "worktree.removed"
14
+ command = ["bash", "scripts/on-worktree-removed.sh"]
15
+
16
+ [[panes]]
17
+ id = "create"
18
+ title = "Create project-local worktree"
19
+ placement = "tab"
20
+ command = ["bash", "scripts/create-worktree.sh"]
21
+
22
+ [[panes]]
23
+ id = "branch-cleanup"
24
+ title = "Delete local branch?"
25
+ placement = "overlay"
26
+ command = ["bash", "scripts/branch-cleanup-pane.sh"]
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "herdr-worktreeinclude",
3
+ "version": "0.1.0",
4
+ "description": "Project-local Git worktrees and .worktreeinclude support for Herdr.",
5
+ "keywords": [
6
+ "herdr",
7
+ "herdr-plugin",
8
+ "git-worktree",
9
+ "worktreeinclude"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/serhii-chernenko/herdr-worktreeinclude.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/serhii-chernenko/herdr-worktreeinclude/issues"
17
+ },
18
+ "homepage": "https://github.com/serhii-chernenko/herdr-worktreeinclude#readme",
19
+ "files": [
20
+ "herdr-plugin.toml",
21
+ "scripts/",
22
+ "README.md",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "scripts": {
26
+ "test": "bash tests/copy-worktreeinclude.sh && bash tests/create-worktree.sh",
27
+ "check": "bash -n scripts/*.sh tests/*.sh && node --check scripts/*.mjs && npm run test",
28
+ "release:check": "node scripts/assert-release-version.mjs"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url))).version;
4
+ const manifest = readFileSync(new URL("../herdr-plugin.toml", import.meta.url), "utf8");
5
+ const manifestVersion = manifest.match(/^version\s*=\s*"([^"]+)"\s*$/m)?.[1];
6
+
7
+ if (!manifestVersion || manifestVersion !== packageVersion) {
8
+ throw new Error(`Version mismatch: package.json=${packageVersion}, herdr-plugin.toml=${manifestVersion ?? "missing"}`);
9
+ }
10
+
11
+ const tag = process.env.GITHUB_REF_NAME;
12
+ if (tag && tag !== `v${packageVersion}`) {
13
+ throw new Error(`Tag ${tag} does not match package version v${packageVersion}`);
14
+ }
15
+
16
+ console.log(`Release version ${packageVersion} is consistent.`);
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # Herdr overlay pane shown after a worktree checkout has been removed.
3
+ set -euo pipefail
4
+
5
+ source_root=${HERDR_WORKTREEINCLUDE_SOURCE:?Missing source repository}
6
+ branch=${HERDR_WORKTREEINCLUDE_BRANCH:?Missing branch name}
7
+
8
+ clear
9
+ printf '\n Worktree checkout removed\n\n'
10
+ printf ' Delete the local branch "%s" too?\n\n' "$branch"
11
+ printf ' [d] Delete branch [Enter] Keep branch\n\n'
12
+ read -r -n 1 choice || true
13
+ printf '\n'
14
+
15
+ if [[ "$choice" == "d" || "$choice" == "D" ]]; then
16
+ git -C "$source_root" branch -d -- "$branch" >&2 || true
17
+ fi
18
+
19
+ herdr_bin=${HERDR_BIN_PATH:-herdr}
20
+ "$herdr_bin" plugin pane close "${HERDR_PANE_ID:?Missing Herdr pane id}" || true
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # Copy source files which are both Git-ignored and selected by .worktreeinclude.
3
+ set -euo pipefail
4
+
5
+ usage() {
6
+ echo "usage: $0 SOURCE_WORKTREE DESTINATION_WORKTREE" >&2
7
+ exit 64
8
+ }
9
+
10
+ [[ $# -eq 2 ]] || usage
11
+ source_root=$(git -C "$1" rev-parse --show-toplevel)
12
+ destination_root=$(git -C "$2" rev-parse --show-toplevel)
13
+ include_file="$source_root/.worktreeinclude"
14
+
15
+ if [[ ! -f "$include_file" ]]; then
16
+ echo "No .worktreeinclude in $source_root; nothing to copy."
17
+ exit 0
18
+ fi
19
+
20
+ # A project-local worktree is physically nested below the primary checkout.
21
+ # Do not treat another checkout's ignored files as source files to copy.
22
+ linked_worktrees=$(git -C "$source_root" worktree list --porcelain | awk '/^worktree / { print substr($0, 10) }')
23
+
24
+ is_other_worktree_path() {
25
+ local candidate=$1 worktree
26
+ while IFS= read -r worktree; do
27
+ [[ -n "$worktree" && "$worktree" != "$source_root" ]] || continue
28
+ case "$candidate" in
29
+ "$worktree"|"$worktree"/*) return 0 ;;
30
+ esac
31
+ done <<<"$linked_worktrees"
32
+ return 1
33
+ }
34
+
35
+ matches=$(mktemp "${TMPDIR:-/tmp}/herdr-worktreeinclude.XXXXXX")
36
+ pattern_root=$(mktemp -d "${TMPDIR:-/tmp}/herdr-worktreeinclude-patterns.XXXXXX")
37
+ trap 'rm -f "$matches"; rm -rf "$pattern_root"' EXIT
38
+
39
+ # Evaluate selection patterns in an otherwise empty Git repository. Evaluating
40
+ # them inside the source repository would also apply its .gitignore, whose
41
+ # higher precedence could defeat a .worktreeinclude negation (for example
42
+ # `config/` followed by `!config/example.json`).
43
+ git -C "$pattern_root" init -q
44
+ cp "$include_file" "$pattern_root/.gitignore"
45
+
46
+ # git ls-files supplies only ignored, untracked paths. check-ignore then applies
47
+ # .worktreeinclude with Git's own .gitignore-compatible pattern engine.
48
+ git -C "$source_root" ls-files --others --ignored --exclude-standard -z \
49
+ | git -C "$pattern_root" check-ignore --no-index --stdin -z >"$matches" || true
50
+
51
+ copied=0
52
+ skipped=0
53
+ while IFS= read -r -d '' relative_path; do
54
+ # Reject paths which could escape either worktree. Git normally cannot emit
55
+ # these, but the guard makes copying safe even with unusual filenames.
56
+ [[ "$relative_path" != /* && "$relative_path" != ../* && "$relative_path" != */../* ]] || continue
57
+
58
+ source_path="$source_root/$relative_path"
59
+ destination_path="$destination_root/$relative_path"
60
+ [[ -e "$source_path" || -L "$source_path" ]] || continue
61
+ is_other_worktree_path "$source_path" && continue
62
+
63
+ # Never overwrite a file Git checked out in the destination.
64
+ if git -C "$destination_root" ls-files --error-unmatch -- "$relative_path" >/dev/null 2>&1; then
65
+ echo "Skipping tracked destination path: $relative_path" >&2
66
+ ((skipped += 1))
67
+ continue
68
+ fi
69
+ if [[ -e "$destination_path" || -L "$destination_path" ]]; then
70
+ echo "Skipping existing destination path: $relative_path" >&2
71
+ ((skipped += 1))
72
+ continue
73
+ fi
74
+
75
+ mkdir -p "$(dirname "$destination_path")"
76
+ # -P preserves symlinks, -R copies recursively if Git reports a directory,
77
+ # and -p retains the source mode and timestamps.
78
+ cp -pPR "$source_path" "$destination_path"
79
+ ((copied += 1))
80
+ done <"$matches"
81
+
82
+ echo "Copied $copied .worktreeinclude file(s); skipped $skipped."
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # Interactive Herdr pane entrypoint. The pane starts in the source workspace.
3
+ set -euo pipefail
4
+
5
+ plugin_root=${HERDR_PLUGIN_ROOT:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"}
6
+ source_root=$(git rev-parse --show-toplevel)
7
+
8
+ read -r -p "Branch name: " branch
9
+ [[ -n "$branch" ]] || { echo "A branch name is required." >&2; exit 64; }
10
+ read -r -p "Base ref [HEAD]: " base
11
+ base=${base:-HEAD}
12
+
13
+ # Keep branches separate without allowing a branch name to traverse directories.
14
+ slug=$(printf '%s' "$branch" | tr '/[:space:]' '--' | tr -cd '[:alnum:]._-')
15
+ [[ -n "$slug" ]] || { echo "Branch name contains no usable path characters." >&2; exit 64; }
16
+ worktree_path="$source_root/.herdr/worktrees/$slug"
17
+
18
+ if [[ -e "$worktree_path" ]]; then
19
+ echo "Destination already exists: $worktree_path" >&2
20
+ exit 1
21
+ fi
22
+
23
+ mkdir -p "$(dirname "$worktree_path")"
24
+ if git show-ref --verify --quiet "refs/heads/$branch"; then
25
+ git worktree add "$worktree_path" "$branch"
26
+ else
27
+ git worktree add -b "$branch" "$worktree_path" "$base"
28
+ fi
29
+
30
+ "$plugin_root/scripts/copy-worktreeinclude.sh" "$source_root" "$worktree_path"
31
+
32
+ herdr_bin=${HERDR_BIN_PATH:-herdr}
33
+ "$herdr_bin" worktree open --cwd "$source_root" --path "$worktree_path" --focus
34
+ echo "Opened $worktree_path in Herdr."
@@ -0,0 +1,8 @@
1
+ const event = JSON.parse(process.env.HERDR_PLUGIN_EVENT_JSON ?? "{}");
2
+
3
+ for (const candidate of [event?.worktree?.path, event?.data?.worktree?.path, event?.payload?.worktree?.path]) {
4
+ if (typeof candidate === "string" && candidate.startsWith("/")) {
5
+ process.stdout.write(candidate);
6
+ break;
7
+ }
8
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # Make Herdr's native worktree dialog project-local, then restore selected
3
+ # ignored files. Herdr v1 emits this hook after it has created the checkout,
4
+ # so relocation necessarily happens immediately after the dialog completes.
5
+ set -euo pipefail
6
+
7
+ plugin_root=${HERDR_PLUGIN_ROOT:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"}
8
+ target=$(node "$plugin_root/scripts/event-worktree-path.mjs")
9
+ [[ -n "$target" ]] || { echo "Could not find worktree path in Herdr event payload." >&2; exit 0; }
10
+
11
+ # The primary worktree provides the shared repository's local ignored files.
12
+ source=$(git -C "$target" worktree list --porcelain | awk '/^worktree / { print substr($0, 10); exit }')
13
+ [[ -n "$source" ]] || exit 0
14
+ branch=$(git -C "$target" branch --show-current)
15
+ [[ -n "$branch" ]] || { echo "Skipping detached worktree: $target" >&2; exit 0; }
16
+ slug=$(printf '%s' "$branch" | tr '[:space:]/' '--' | tr -cd '[:alnum:]._-')
17
+ [[ -n "$slug" ]] || { echo "Could not make a safe path from branch: $branch" >&2; exit 1; }
18
+ destination="$source/.herdr/worktrees/$slug"
19
+
20
+ # A plugin-created project-local worktree already has the right path.
21
+ if [[ "$target" != "$destination" ]]; then
22
+ if [[ -e "$destination" ]]; then
23
+ echo "Project-local worktree path already exists; leaving Herdr worktree at $target" >&2
24
+ exit 1
25
+ fi
26
+
27
+ herdr_bin=${HERDR_BIN_PATH:-herdr}
28
+ # Capture the workspace while Herdr still recognizes the original path.
29
+ workspace_id=$("$herdr_bin" worktree list --cwd "$source" --json \
30
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=JSON.parse(s).result;const w=(r.worktrees||[]).find(x=>x.path===process.argv[1]);if(w?.open_workspace_id)process.stdout.write(w.open_workspace_id)})' "$target")
31
+ "$plugin_root/scripts/copy-worktreeinclude.sh" "$source" "$target"
32
+ mkdir -p "$(dirname "$destination")"
33
+ git -C "$source" worktree move "$target" "$destination"
34
+
35
+ # Herdr's workspace is still attached to the old path. Close it, then open
36
+ # the relocated checkout as the replacement workspace.
37
+ [[ -z "$workspace_id" ]] || "$herdr_bin" workspace close "$workspace_id"
38
+ "$herdr_bin" worktree open --cwd "$source" --path "$destination" --focus
39
+ echo "Relocated worktree to $destination"
40
+ else
41
+ "$plugin_root/scripts/copy-worktreeinclude.sh" "$source" "$target"
42
+ fi
43
+
44
+ # Store the final checkout path. The removal event occurs after Git has
45
+ # deleted that checkout, so this is how the cleanup pane knows its branch and
46
+ # source repository.
47
+ managed_path=${destination:-$target}
48
+ state_dir=${HERDR_PLUGIN_STATE_DIR:-"$plugin_root/.state"}
49
+ state_file="$state_dir/managed-worktrees.tsv"
50
+ mkdir -p "$state_dir"
51
+ state_tmp=$(mktemp "$state_dir/managed-worktrees.XXXXXX")
52
+ if [[ -f "$state_file" ]]; then
53
+ awk -F '\t' -v path="$managed_path" '$1 != path' "$state_file" >"$state_tmp"
54
+ fi
55
+ printf '%s\t%s\t%s\n' "$managed_path" "$source" "$branch" >>"$state_tmp"
56
+ mv "$state_tmp" "$state_file"
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # Open an in-Herdr branch-cleanup overlay after a plugin-managed checkout is
3
+ # removed. This hook deliberately does not use macOS dialogs.
4
+ set -euo pipefail
5
+
6
+ plugin_root=${HERDR_PLUGIN_ROOT:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"}
7
+ target=$(node "$plugin_root/scripts/event-worktree-path.mjs")
8
+ [[ -n "$target" ]] || exit 0
9
+
10
+ state_dir=${HERDR_PLUGIN_STATE_DIR:-"$plugin_root/.state"}
11
+ state_file="$state_dir/managed-worktrees.tsv"
12
+ [[ -f "$state_file" ]] || exit 0
13
+ record=$(awk -F '\t' -v path="$target" '$1 == path { print; exit }' "$state_file")
14
+ [[ -n "$record" ]] || exit 0
15
+ IFS=$'\t' read -r _ source branch <<<"$record"
16
+
17
+ state_tmp=$(mktemp "$state_dir/managed-worktrees.XXXXXX")
18
+ awk -F '\t' -v path="$target" '$1 != path' "$state_file" >"$state_tmp"
19
+ mv "$state_tmp" "$state_file"
20
+
21
+ herdr_bin=${HERDR_BIN_PATH:-herdr}
22
+
23
+ # If the branch tip is already contained in the source checkout's HEAD, no
24
+ # work remains unique to the worktree. Delete it without interrupting the UI.
25
+ if git -C "$source" merge-base --is-ancestor "$branch" HEAD; then
26
+ git -C "$source" branch -d -- "$branch" >&2 || true
27
+ exit 0
28
+ fi
29
+
30
+ "$herdr_bin" plugin pane open \
31
+ --plugin "${HERDR_PLUGIN_ID:-serhii-chernenko.worktreeinclude}" \
32
+ --entrypoint branch-cleanup \
33
+ --placement overlay \
34
+ --env "HERDR_WORKTREEINCLUDE_SOURCE=$source" \
35
+ --env "HERDR_WORKTREEINCLUDE_BRANCH=$branch" \
36
+ --focus