Skip to content

Implement worktree-link CLI tool - #1

Merged
km-tr merged 31 commits into
mainfrom
claude/worktree-link-cli-aOlwa
Feb 24, 2026
Merged

km-tr merged 31 commits into
mainfrom
claude/worktree-link-cli-aOlwa

Conversation

@km-tr

@km-tr km-tr commented Feb 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • Implement the worktree-link Rust CLI tool that reads .worktreelinks config files with gitignore-style glob patterns and creates symlinks between git worktrees
  • Covers Phase 1 (MVP) and Phase 2 (safety/convenience) features from the spec
  • Architecture: cli.rs (clap args), config.rs (parser), walker.rs (pattern matching via ignore crate), linker.rs (symlink create/remove), error.rs (error types), main.rs (orchestration)

Features

  • Pattern matching — gitignore-compatible globs (node_modules, .env.*, packages/*/node_modules, etc.)
  • Directory collapsing — directories like node_modules are symlinked as a unit, not file-by-file
  • --dry-run — preview without making changes
  • --force — overwrite existing files/symlinks
  • --unlink — remove symlinks pointing back to the source
  • --verbose — structured logging via tracing
  • .git exclusion — always skipped for safety
  • Colored output — LINK (green), SKIP (yellow), OVERWRITE (magenta), UNLINK (red)

Test plan

  • Unit tests pass (cargo test — 5 tests for config parsing + directory collapsing)
  • Integration tested: link creation, dry-run, skip (existing), --force overwrite, --unlink removal
  • Verified symlinks use absolute paths
  • Verified .git and non-matching files are correctly excluded

https://claude.ai/code/session_01HVMBXoJvcrU1ouT2MrZNMC

Rust CLI tool that reads .worktreelinks config files with gitignore-style
glob patterns and creates symlinks between git worktrees. Implements all
features from the spec:

- CLI argument parsing with clap (source, target, --config, --dry-run,
  --force, --verbose, --unlink)
- .worktreelinks config file parser (comments, blank lines, glob patterns)
- Pattern matching using the ignore crate's OverrideBuilder
- Directory-level symlink collapsing (node_modules linked as a whole)
- Symlink creation with absolute paths
- --force mode to overwrite existing files/links
- --unlink mode to remove previously created symlinks
- --dry-run mode to preview changes
- --verbose mode with tracing-based structured logging
- .git directory exclusion for safety
- Colored terminal output for link/skip/unlink/overwrite actions

https://claude.ai/code/session_01HVMBXoJvcrU1ouT2MrZNMC
@coderabbitai

coderabbitai Bot commented Feb 24, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)

ウォークスルー

このプルリクエストは、GitIgnoreパターンに基づいてソースディレクトリからターゲットディレクトリへのシンボリックリンクを管理する新しいRustコマンドラインツール「worktree-link」を導入します。CLIパーサー、設定ファイルローダー、ターゲット収集ウォーカー、リンク作成・削除ロジックを含むモジュール群で構成されています。

変更内容

コホート / ファイル(s) 概要
プロジェクト設定
.gitignore, LICENSE, README.md
ビルドアーティファクトの除外、MITライセンス、使用方法・インストール・設定ドキュメントを追加。
CLIインターフェース
src/cli.rs
Clapを使用してコマンドライン引数をパースするCli構造体を定義。ソース/ターゲットパス、設定ファイル、ドライラン/強制/詳細ログ/アンリンクオプションを含む。
設定管理
src/config.rs
設定ファイルからシンボリックリンク対象のパターンを読み込むConfig構造体を実装。コメント・空行をフィルタリング。
ファイルウォーキング
src/walker.rs
GitIgnore風のパターンを使用してソースディレクトリを走査し、対象ファイル/ディレクトリを収集。ビルダーパターンでオーバーライドセットを構築。
リンク操作
src/linker.rs
シンボリックリンクの作成(上書き可能)および削除(指定方向)を管理。アクション追跡、エラーハンドリング、クロスプラットフォーム対応。
バイナリエントリーポイント
src/main.rs
CLI解析、設定読み込み、ターゲット走査、リンク/アンリンク実行を統合。詳細ログ、エラーハンドリング、実行結果をまとめて表示。

シーケンス図

sequenceDiagram
    participant User
    participant CLI as CLI Parser
    participant Config as Config Loader
    participant Walker as Target Walker
    participant Linker as Link Manager
    participant FS as Filesystem

    User->>CLI: worktree-link --source <src> --target <tgt> --config <cfg>
    CLI->>Config: load patterns from config file
    Config->>FS: read config file
    FS-->>Config: patterns (lines)
    Config-->>CLI: parsed patterns
    CLI->>Walker: collect_targets(source, patterns)
    Walker->>FS: walk source with gitignore-like filters
    FS-->>Walker: matched paths
    Walker-->>CLI: sorted target list
    CLI->>Linker: create_link for each target
    loop per matched target
        Linker->>FS: create or overwrite symlink
        FS-->>Linker: LinkAction (Created/Overwritten/Skipped)
    end
    Linker-->>CLI: all actions
    CLI->>User: summary (created/overwritten/skipped)
Loading
sequenceDiagram
    participant User
    participant CLI as CLI Parser
    participant Linker as Link Manager
    participant FS as Filesystem

    User->>CLI: worktree-link --source <src> --target <tgt> --unlink
    CLI->>Linker: unlink_targets(source, target)
    Linker->>FS: walk target directory (non-following)
    FS-->>Linker: all symlinks
    loop per symlink pointing to source
        Linker->>FS: remove symlink
        FS-->>Linker: UnlinkAction (Removed/Skipped)
    end
    Linker-->>CLI: all actions (sorted)
    CLI->>User: summary (removed/skipped)
Loading

推定コードレビュー難易度

🎯 4 (複雑) | ⏱️ ~45 分

詩

🐰 新しいツール、リンクを束ね
ソースからターゲットへ、パターン読み取る
シンボルで繋ぐ、ドライラン確認
ワーカー、リンカー、設定をまとめて
Rustの力で、ワークツリー整う🔗

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: implementing the worktree-link CLI tool, which is the primary objective of this pull request.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering features, architecture, test results, and implementation details of the worktree-link CLI tool.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/worktree-link-cli-aOlwa

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce6dfc2e5f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/linker.rs Outdated
Comment thread src/main.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/main.rs (1)

52-55: source/target が入れ子の場合のガードを追加したいです。

同一ディレクトリは弾いていますが、どちらかがもう片方の配下だと自己リンクや意図しない走査対象が生まれやすいので、早めに拒否するのが安全です。

✅ 例: 入れ子禁止の追加
     if source == target {
         bail!("Source and target cannot be the same directory");
     }
+    if target.starts_with(&source) || source.starts_with(&target) {
+        bail!("Source and target must not be nested");
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.rs` around lines 52 - 55, source と target
が同一ディレクトリのみでなく一方が他方の配下(入れ子)になっているケースを拒否するチェックを追加してください: source と target を
fs::canonicalize して正規化し(エラーが出れば bail! で失敗させるか適切にハンドル)、同一であるか既にある判定に加えて
canonical_source.starts_with(&canonical_target) または
canonical_target.starts_with(&canonical_source) を評価し、どちらかが true なら現在の
bail!("Source and target cannot be the same directory")
と同様に早期に拒否するようにしてください(関数内の source/target 変数を使って実装してください)。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/walker.rs`:
- Around line 7-53: The code currently walks the entire tree and only prunes
matched directories later; change collect_targets to stop descending into
matched directories by using the WalkBuilder.filter_entry closure to check
overrides.matched and return false for directories that match. Wrap overrides in
a shareable container (e.g. Arc) so the closure can call overrides.matched(path,
is_dir) and if it returns Match::Whitelist(_) and is_dir==true return false (but
still allow the entry itself so you can push it into targets), while keeping the
existing ".git" check; update the WalkBuilder.filter_entry closure accordingly.

---

Nitpick comments:
In `@src/main.rs`:
- Around line 52-55: source と target
が同一ディレクトリのみでなく一方が他方の配下(入れ子)になっているケースを拒否するチェックを追加してください: source と target を
fs::canonicalize して正規化し(エラーが出れば bail! で失敗させるか適切にハンドル)、同一であるか既にある判定に加えて
canonical_source.starts_with(&canonical_target) または
canonical_target.starts_with(&canonical_source) を評価し、どちらかが true なら現在の
bail!("Source and target cannot be the same directory")
と同様に早期に拒否するようにしてください(関数内の source/target 変数を使って実装してください)。

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 69a8fdb and ce6dfc2.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (7)
  • .gitignore
  • src/cli.rs
  • src/config.rs
  • src/error.rs
  • src/linker.rs
  • src/main.rs
  • src/walker.rs

Comment thread src/walker.rs Outdated
…detection, nested guard

- linker.rs: Stop canonicalizing source_path in create_link() to preserve
  symlinks as-is. Previously, canonicalize resolved symlinks to their
  ultimate target, breaking the link/unlink round-trip when source entries
  were themselves symlinks (P1 review).

- walker.rs: Use filter_entry to prune matched directories immediately
  instead of walking the entire subtree and collapsing afterward. This
  avoids unnecessary I/O for large directories like node_modules (Major
  review). Also added proper unit tests for collect_targets.

- linker.rs: Rewrite unlink_targets to walk the target directory looking
  for symlinks that point into source, instead of deriving candidates from
  the source tree. This catches stale symlinks whose source-side originals
  have been deleted or renamed (P2 review).

- main.rs: Add guard rejecting nested source/target directories to prevent
  self-linking and unintended traversal (Nitpick review).

https://claude.ai/code/session_01HVMBXoJvcrU1ouT2MrZNMC
@km-tr

km-tr commented Feb 24, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/walker.rs (1)

44-71: filter_entry によるディレクトリ枝刈りが正しく実装されている

以前のレビューで指摘されていた「マッチしたディレクトリに不要に走査する」問題が修正されています。filter_entry でマッチしたディレクトリを matched_dirs に記録しつつ false を返すことで、走査が正しく打ち切られています。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/walker.rs` around lines 44 - 71, The filter_entry closure correctly
prunes matched directories by checking overrides_clone.matched(path, true),
pushing the directory into matched_dirs_clone via
matched_dirs_clone.lock().unwrap().push(path.to_path_buf()), and returning false
to stop descent; no functional change required—leave filter_entry,
matched_dirs_clone and the overrides_clone.matched call as-is (optionally add a
brief comment above the matched_dirs_clone.lock() call to explain the
lock/unwrap and early-return behavior for future readers).
🧹 Nitpick comments (4)
src/walker.rs (2)

163-169: テスト用一時ディレクトリのクリーンアップが不完全

tempdir ヘルパーはテスト開始時に前回の残骸を削除しますが、テスト終了後のクリーンアップがありません。テストが失敗した場合やテスト名が変わった場合に、ゴミが残ります。tempfile クレートの TempDir を使うと RAII でスコープ終了時に自動削除されます。

♻️ tempfile クレートを使った改善案

Cargo.toml の [dev-dependencies] に tempfile を追加した上で:

+    use tempfile::TempDir;
+
     #[test]
     fn collect_targets_skips_unmatched() {
-        let dir = tempdir("collect_skip");
+        let dir = TempDir::new().unwrap();
+        let dir = dir.path();
         fs::write(dir.join(".env"), "SECRET=1").unwrap();

各テストで同様のパターンを適用すれば、テスト後にディレクトリが自動削除されます。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/walker.rs` around lines 163 - 169, The tempdir helper currently creates
and canonicalizes a PathBuf but never removes the directory after tests; replace
uses of the tempdir(name: &str) -> PathBuf helper with tempfile::TempDir so
directories are removed via RAII: add tempfile to [dev-dependencies] in
Cargo.toml, change the helper (or each test) to call
tempfile::Builder::new().prefix(&format!("worktree-link-test-{name}")).tempdir().unwrap()
(keep references to the TempDir handle in the test scope) instead of calling
tempdir(), and update any places that relied on the returned PathBuf (use
tempdir.path() for file operations) so cleanup happens automatically when
TempDir is dropped.

32-33: シーケンシャル Walk での Arc<Mutex<>> は型制約のためだけに存在する点にコメントを追加すると良い

WalkBuilder::build() はシングルスレッドの Walk イテレータを返しますが、filter_entry のシグネチャが Send + 'static を要求するため Arc<Mutex<>> が必要になっています。実行時にはロック競合は発生しません。この理由をコードコメントに残しておくと、将来のメンテナが不要な同期に見えて混乱するのを防げます。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/walker.rs` around lines 32 - 33, Add a brief code comment next to the
matched_dirs declaration (the Arc<std::sync::Mutex<Vec<PathBuf>>> bound used by
Walk) explaining that WalkBuilder::build() returns a single-threaded Walk
iterator but filter_entry requires its closure to be Send + 'static, so the
Arc<Mutex<...>> exists only to satisfy the type/lifetime/thread-safety
constraints and not because concurrent access occurs at runtime (no lock
contention expected); reference Walk, WalkBuilder::build(), and filter_entry in
the comment so future maintainers understand the reason.
README.md (1)

95-109: 対応プラットフォームについての記載がない

src/linker.rs のシンボリックリンク作成は Unix 専用で、非 Unix 環境では bail! でエラーになります。README の「Safety」セクションまたは別セクションに、現在 Unix(Linux / macOS)のみ対応であることを明記するのが望ましいです。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 95 - 109, The README lacks a platforms note: in
src/linker.rs the symlink creation path is Unix-only and returns an error via
bail! on non-Unix systems, so update the README (in the Safety section or a new
"Supported platforms" section) to state the tool currently supports Unix-like
systems (Linux and macOS) only; reference the Unix-only symlink creation in
src/linker.rs (the function that performs symlink creation / erroring with
bail!) and mention that Windows is not supported yet and will error at runtime
unless implemented.
src/linker.rs (1)

229-232: 非 Unix 環境でのフォールバックは bail! で即座に失敗する

Windows 環境では std::os::windows::fs::symlink_dir / symlink_file を使えば対応可能ですが、現状 Unix 専用ツールとして割り切るのであればこれで問題ありません。README に「Unix のみ対応」と明記することを推奨します。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/linker.rs` around lines 229 - 232, The non-Unix fallback in the symlink
function currently bails immediately; implement Windows support instead of
failing by using std::os::windows::fs::symlink_dir and symlink_file: in
src/linker.rs replace the #[cfg(not(unix))] symlink stub with a Windows-specific
implementation that checks source.metadata()?.is_dir() and calls
symlink_dir(source, target) for directories or symlink_file(source, target) for
files, propagating errors as anyhow::Error; keep the function signature fn
symlink(source: &Path, target: &Path) -> Result<()> and ensure you import
std::os::windows::fs::{symlink_dir, symlink_file} and use anyhow::Context on IO
calls for clearer errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 20-22: README の使用例の fenced code block に言語指定が無いため markdownlint
MD040 を引いています。該当ブロック(内容: "worktree-link [OPTIONS] <SOURCE> [TARGET]") を見つけて、開閉の
``` に続けて言語タグ(例: text または console)を追加してください(例: ```text)。これで静的解析の警告が解消されます。
- Around line 50-53:
ドキュメントのサンプルが誤解を招くため、同じコマンド群の中で「--dry-run」例と実行例が連続して見えないように修正してください。具体的には README
の該当ブロックで `worktree-link --dry-run /path/to/main` と `worktree-link /path/to/main`
を分離し、どちらかに「これは確認のみで実際の変更は行わない」などの説明コメントを追加するか、別の見出し/サブセクションに移してワークフロー(確認→実行)であることを明示してください(参照シンボル:
the two example commands `worktree-link --dry-run /path/to/main` と
`worktree-link /path/to/main`)。

In `@src/linker.rs`:
- Around line 140-165: The current closure passed to walk_symlinks inside
unlink_targets propagates errors with `?`, aborting the whole walk; change it to
handle per-entry failures by catching errors from `fs::read_link` and
`remove_entry` (and any other fallible calls) instead of using `?`, log a
warning (e.g., via `warn!`) and push an `UnlinkAction::Skipped { target:
entry_path.clone(), reason: format!("...: {}", err) }` so processing continues
for remaining entries; preserve the existing success path (push
`UnlinkAction::Removed(entry_path)` and `info!`), and ensure `dry_run` behavior
remains unchanged.
- Around line 145-155: fs::read_link(&entry_path) can return a
relative/non-normalized path so starts_with(source_dir) may misfire;
canonicalize the link target (resolve relative targets against
entry_path.parent()) before comparing to source_dir. Replace the direct
read_link() -> starts_with check with code that: (1) obtains link_dest_raw =
fs::read_link(&entry_path), (2) builds an absolute path for the target by if
link_dest_raw.is_absolute() { fs::canonicalize(&link_dest_raw) } else {
fs::canonicalize(entry_path.parent().unwrap().join(&link_dest_raw)) }, (3) use
that canonicalized path in the starts_with(source_dir) check and in the
UnlinkAction::Skipped reason, and propagate/contextualize any canonicalize
errors similarly to the existing with_context calls.

---

Duplicate comments:
In `@src/walker.rs`:
- Around line 44-71: The filter_entry closure correctly prunes matched
directories by checking overrides_clone.matched(path, true), pushing the
directory into matched_dirs_clone via
matched_dirs_clone.lock().unwrap().push(path.to_path_buf()), and returning false
to stop descent; no functional change required—leave filter_entry,
matched_dirs_clone and the overrides_clone.matched call as-is (optionally add a
brief comment above the matched_dirs_clone.lock() call to explain the
lock/unwrap and early-return behavior for future readers).

---

Nitpick comments:
In `@README.md`:
- Around line 95-109: The README lacks a platforms note: in src/linker.rs the
symlink creation path is Unix-only and returns an error via bail! on non-Unix
systems, so update the README (in the Safety section or a new "Supported
platforms" section) to state the tool currently supports Unix-like systems
(Linux and macOS) only; reference the Unix-only symlink creation in
src/linker.rs (the function that performs symlink creation / erroring with
bail!) and mention that Windows is not supported yet and will error at runtime
unless implemented.

In `@src/linker.rs`:
- Around line 229-232: The non-Unix fallback in the symlink function currently
bails immediately; implement Windows support instead of failing by using
std::os::windows::fs::symlink_dir and symlink_file: in src/linker.rs replace the
#[cfg(not(unix))] symlink stub with a Windows-specific implementation that
checks source.metadata()?.is_dir() and calls symlink_dir(source, target) for
directories or symlink_file(source, target) for files, propagating errors as
anyhow::Error; keep the function signature fn symlink(source: &Path, target:
&Path) -> Result<()> and ensure you import std::os::windows::fs::{symlink_dir,
symlink_file} and use anyhow::Context on IO calls for clearer errors.

In `@src/walker.rs`:
- Around line 163-169: The tempdir helper currently creates and canonicalizes a
PathBuf but never removes the directory after tests; replace uses of the
tempdir(name: &str) -> PathBuf helper with tempfile::TempDir so directories are
removed via RAII: add tempfile to [dev-dependencies] in Cargo.toml, change the
helper (or each test) to call
tempfile::Builder::new().prefix(&format!("worktree-link-test-{name}")).tempdir().unwrap()
(keep references to the TempDir handle in the test scope) instead of calling
tempdir(), and update any places that relied on the returned PathBuf (use
tempdir.path() for file operations) so cleanup happens automatically when
TempDir is dropped.
- Around line 32-33: Add a brief code comment next to the matched_dirs
declaration (the Arc<std::sync::Mutex<Vec<PathBuf>>> bound used by Walk)
explaining that WalkBuilder::build() returns a single-threaded Walk iterator but
filter_entry requires its closure to be Send + 'static, so the Arc<Mutex<...>>
exists only to satisfy the type/lifetime/thread-safety constraints and not
because concurrent access occurs at runtime (no lock contention expected);
reference Walk, WalkBuilder::build(), and filter_entry in the comment so future
maintainers understand the reason.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ce6dfc2 and 81e79de.

⛔ Files ignored due to path filters (1)
  • Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (5)
  • LICENSE
  • README.md
  • src/linker.rs
  • src/main.rs
  • src/walker.rs
✅ Files skipped from review due to trivial changes (1)
  • LICENSE
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main.rs

Comment thread README.md Outdated
Comment thread README.md
Comment thread src/linker.rs
Comment thread src/linker.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81e79de1a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main.rs Outdated
Comment thread src/linker.rs
claude and others added 2 commits February 24, 2026 05:20
- README: Add language specifier to fenced code block (MD040)
- README: Clarify dry-run → actual run workflow in examples
- linker.rs: Make walk_symlinks best-effort — warn and skip on
  permission errors and individual entry failures instead of aborting
- linker.rs: Resolve relative symlink targets before starts_with
  comparison in unlink_targets (fs::read_link can return relative paths)
- linker.rs: Guard --force from deleting through symlinked parents
  to prevent destroying source data when a parent is already a symlink
- main.rs: Move config parsing inside link branch so --unlink works
  without a .worktreelinks file (unlink only scans target for symlinks)

https://claude.ai/code/session_01HVMBXoJvcrU1ouT2MrZNMC
@km-tr

km-tr commented Feb 24, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 345002db14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/linker.rs
Comment thread src/linker.rs
@km-tr

km-tr commented Feb 24, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 24, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

- Add has_symlink_parent check to the non-overwrite create path so
  links are never created through symlinked parent directories
- Skip .git directories in walk_symlinks to protect repository internals
  during --unlink

https://claude.ai/code/session_01HVMBXoJvcrU1ouT2MrZNMC
@km-tr

km-tr commented Feb 24, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a93d05b4a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/linker.rs Outdated
…nlink

Resolve relative symlink targets with fs::canonicalize to correctly handle
`..` segments before comparing with source_dir via starts_with. Falls back
to non-canonicalized path for broken (dangling) symlinks to avoid errors.

🤖 Generated with AI
Copilot AI review requested due to automatic review settings February 24, 2026 06:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/linker.rs`:
- Around line 83-145: Add an explicit validation at the start of create_link to
ensure source_path.is_absolute() is true; if not, return an Err with a clear
context/error (e.g., using anyhow::bail or Err(anyhow!(...)) so callers get
"source_path must be absolute" rather than accidentally creating a relative
symlink. Place this guard before any filesystem operations (before the existing
debug! and checks), referencing the create_link function and the source_path
parameter.
- Around line 178-196: The current logic may leave `..` segments in `resolved`
when `fs::canonicalize(&resolved)` fails, causing
`resolved.starts_with(source_dir)` to mis-evaluate; update the code around the
`resolved` handling (the variables `link_dest`, `entry_path`, `resolved`, and
the `fs::canonicalize` fallback) to perform lexical normalization when
canonicalization fails: after the relative join fallback, run a small
normalization routine that collapses `.` segments and resolves `..` against the
preceding components (ensuring the path remains absolute by preserving the
leading root or drive), and then use that normalized `resolved` for the
`starts_with(source_dir)` check; implement this normalization as a helper
function (e.g., `lexical_normalize_path`) and call it in place of leaving the
un-normalized `resolved`.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 345002d and 5625b73.

📒 Files selected for processing (1)
  • src/linker.rs

Comment thread src/linker.rs
Comment thread src/linker.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a new Rust CLI (worktree-link) that reads .worktreelinks (gitignore-style patterns), finds matching paths in a source worktree, and creates/removes symlinks in a target worktree with safety flags and colored output.

Changes:

  • Added CLI argument parsing and main orchestration for link/unlink modes (--dry-run, --force, --verbose, --unlink).
  • Implemented .worktreelinks parsing and filesystem walking with gitignore-compatible matching and directory “collapse” behavior.
  • Implemented symlink creation/removal logic with basic safety guards, plus docs, CI, and crate metadata.

Reviewed changes

Copilot reviewed 10 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/cli.rs Defines clap-based CLI interface and flags.
src/config.rs Parses .worktreelinks patterns (comments/blank lines).
src/walker.rs Walks source tree and collects matched files/dirs using ignore overrides.
src/linker.rs Creates symlinks (with --force/--dry-run) and unlinks symlinks pointing into source.
src/main.rs Wires CLI → config → walker → linker; sets up tracing and output.
src/error.rs Adds typed error enum (currently unused).
README.md Documents installation, usage, config format, and behavior.
Cargo.toml / Cargo.lock Adds Rust crate definition and dependencies.
.github/workflows/ci.yml Adds Rust CI (check/test/fmt/clippy).
.gitignore Ignores /target.
LICENSE Adds MIT license text.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/linker.rs
Comment thread src/main.rs Outdated
Comment thread src/linker.rs
Comment thread README.md
km-tr and others added 3 commits February 24, 2026 15:55
… clippy CI

- Canonicalize source_dir in unlink_targets so starts_with comparison
  works correctly against fully-resolved link destinations
- Add [dry-run] prefix to info! logs in create_link to distinguish
  dry-run from actual operations
- Add -- -D warnings to clippy CI step to fail on clippy lint warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
RUSTFLAGS=-Dwarnings already covers clippy lints since clippy wraps rustc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
km-tr and others added 2 commits February 24, 2026 16:02
…tion, cleanup

- Add is_absolute() guard to create_link for defensive input validation
- Use lexical path normalization as fallback when fs::canonicalize fails
  on dangling symlinks, so `..` segments are still resolved correctly
- Remove unused error module (anyhow is used throughout)
- Document Unix-only platform support in README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/main.rs (1)

119-121: strip_prefix のエラーコンテキストにパスを含めてください(任意)

この ? が実際に発動することはほぼありませんが、万一発動した際のデバッグ性を高めるためパスを含めると有用です。

🔧 改善案
-            let rel = source_path
-                .strip_prefix(&source)
-                .with_context(|| "Path is not relative to source")?;
+            let rel = source_path
+                .strip_prefix(&source)
+                .with_context(|| format!("Path {} is not relative to source {}", source_path.display(), source.display()))?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.rs` around lines 119 - 121, 現在の strip_prefix 呼び出しは固定メッセージ "Path is
not relative to source" のみを返しており、失敗時のデバッグ情報が不足しています。source_path と source
の情報をエラーコンテキストに含めるように with_context クロージャ内のメッセージを具体化してください(たとえば
source_path.display() と source.display()
を組み込んだ説明的な文字列を返す)ことで、source_path.strip_prefix(&source)
の失敗時にどのパスが問題だったか分かるようにしてください。
src/linker.rs (1)

299-314: normalize_lexically がルートより上への .. でルートコンポーネントを誤ってポップする可能性がある

シンボリックリンクのエントリが浅いパス(例: /a/link)にあり、その link_dest が ../../source/foo のように深さを超える .. を含む場合、entry_path.parent().join(link_dest) が /a/../../source/foo となり、normalize_lexically は RootDir コンポーネントまでポップして source/foo という相対パスを返します。その結果、starts_with(&canonical_source) が常に false となり、対象シンボリックリンクが削除されないフォールスネガティブが発生します。

実際のワークツリーは通常ホームディレクトリ以下に存在するためほぼ影響はありませんが、ルートに近い浅いパスでは発生し得ます。

🔧 修正案:ルートコンポーネントをポップしない
         Component::ParentDir => {
-            if !parts.is_empty() {
+            let can_pop = parts.last().map(|c| {
+                !matches!(c, Component::RootDir | Component::Prefix(_))
+            }).unwrap_or(false);
+            if can_pop {
                 parts.pop();
             }
         }
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 109-111: README の「Existing files/symlinks are never
overwritten...」の記述が実際と異なるため修正してください:linker.rs の remove_entry 実装は --force 時に
std::fs::remove_dir_all を呼んで通常ディレクトリを再帰削除するので、README の該当箇所("files/symlinks"
の表現)を「既存のファイル・シンボリックリンクは上書きされないが、--force
を指定すると実ディレクトリも再帰削除され得る(取り扱いに注意)」のように正確に書き換えて、参照として linker.rs の remove_entry と
remove_dir_all の挙動を明示してください。

In `@src/linker.rs`:
- Around line 222-234: The info log and action push after the removal are
unconditional, so when dry_run is true we still log "unlinked:" and push
UnlinkAction::Removed; update the unlinking logic in unlink_targets to branch on
the dry_run flag: when dry_run is true, log with a "[dry-run] " prefix (e.g.
"[dry-run] unlinked: {}", using entry_path) and push UnlinkAction::Skipped {
target: entry_path, reason: "dry-run" } (or similar), and when dry_run is false
keep the current behavior of calling remove_entry, logging "unlinked: {}", and
pushing UnlinkAction::Removed(entry_path); reference remove_entry,
UnlinkAction::Removed, UnlinkAction::Skipped, dry_run, and entry_path to locate
the changes.

---

Nitpick comments:
In `@src/main.rs`:
- Around line 119-121: 現在の strip_prefix 呼び出しは固定メッセージ "Path is not relative to
source" のみを返しており、失敗時のデバッグ情報が不足しています。source_path と source の情報をエラーコンテキストに含めるように
with_context クロージャ内のメッセージを具体化してください(たとえば source_path.display() と
source.display() を組み込んだ説明的な文字列を返す)ことで、source_path.strip_prefix(&source)
の失敗時にどのパスが問題だったか分かるようにしてください。

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5625b73 and 414147f.

📒 Files selected for processing (3)
  • README.md
  • src/linker.rs
  • src/main.rs

Comment thread README.md
Comment thread src/linker.rs
km-tr and others added 4 commits February 24, 2026 17:12
- Update README safety section to clarify --force removes directories
  recursively, not just files/symlinks
- Add [dry-run] prefix to unlink info! log for consistency with
  create_link logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Set up cargo-dist for automated release builds and Homebrew formula
generation. On tag push, builds binaries for macOS (arm64/x86_64)
and Linux (x86_64/arm64), creates GitHub Release, and publishes
Homebrew formula to this repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Configure cargo-release for streamlined version bumping and tagging.
Disable crates.io publish; distribution is via GitHub Releases only.

Usage: cargo release patch --execute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
README.md (1)

48-53: ⚠️ Potential issue | 🟡 Minor

--force の説明が本文と不整合です。
オプション表では “files/symlinks” のみ対象に見えますが、本文ではディレクトリも再帰削除される旨が記載されています。表側もディレクトリ削除を明記しておくと誤解が減ります。

📝 修正案
-| `-f, --force` | Overwrite existing files/symlinks | `false` |
+| `-f, --force` | Overwrite existing files, symlinks, and directories (directories removed recursively) | `false` |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 48 - 53, Update the `-f, --force` option description
in the README table to match the body text by explicitly mentioning that
directories may be overwritten/recursively removed (e.g., change "Overwrite
existing files/symlinks" to "Overwrite existing files, symlinks and directories
(may remove directories recursively)"), ensuring the `-f, --force` row text
aligns with the documented behavior elsewhere.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/linker.rs`:
- Around line 300-318: normalize_lexically currently pops components even past a
root/prefix which can convert absolute paths to relative and drop leading `..`;
update normalize_lexically so it preserves leading RootDir/Prefix components and
preserves leading ParentDir components for relative paths: when handling
Component::ParentDir, only pop if the last pushed component is a normal path
segment (not RootDir/Prefix) and if parts is non-empty; if parts is empty and
the input path is relative (no leading RootDir/Prefix was pushed) then push the
ParentDir component instead of dropping it; ensure you continue to push RootDir
and Prefix components unchanged so starts_with checks (like in unlink_targets)
remain valid.

---

Duplicate comments:
In `@README.md`:
- Around line 48-53: Update the `-f, --force` option description in the README
table to match the body text by explicitly mentioning that directories may be
overwritten/recursively removed (e.g., change "Overwrite existing
files/symlinks" to "Overwrite existing files, symlinks and directories (may
remove directories recursively)"), ensuring the `-f, --force` row text aligns
with the documented behavior elsewhere.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 414147f and 57ddfb5.

⛔ Files ignored due to path filters (3)
  • .github/workflows/release.yml is excluded by !**/*.yml
  • Cargo.toml is excluded by !**/*.toml
  • dist-workspace.toml is excluded by !**/*.toml
📒 Files selected for processing (2)
  • README.md
  • src/linker.rs

Comment thread src/linker.rs
Add tap and publish-jobs configuration to dist-workspace.toml to generate
publish-homebrew-formula job that commits and pushes worktree-link.rb to
the tap repository on release. Requires HOMEBREW_TAP_TOKEN secret.

🤖 Generated with AI
Pin cargo-dist and cargo-release versions via mise so team members
can install exact versions with mise install.

🤖 Generated with AI
Guard against ParentDir components popping RootDir or Prefix
components, which would turn absolute paths into relative ones
and break the starts_with comparison in unlink_targets.

🤖 Generated with AI
@km-tr km-tr self-assigned this Feb 24, 2026
km-tr and others added 8 commits February 24, 2026 18:04
Skip fmt/clippy/test when no .rs or .toml files are staged,
e.g. for README-only commits.

🤖 Generated with AI
Tests are run in CI; pre-commit only needs fmt and clippy.

🤖 Generated with AI
Fix glob patterns to **/*.rs so hooks trigger for files in
subdirectories. Add -D warnings to clippy to fail on lint warnings.

🤖 Generated with AI
Add doc comment to config parser explaining inline # is not stripped
(matching .gitignore semantics). Remove manual [default: current
directory] from cli.rs to avoid duplication with clap auto-generated
default.

🤖 Generated with AI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@km-tr
km-tr merged commit 048d82a into main Feb 24, 2026
10 checks passed
@github-actions github-actions Bot mentioned this pull request Feb 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants