From 0340d5387f7e98859643a5417f4a5bf1c0a5d8af Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 10:26:42 +0800 Subject: [PATCH 01/19] refactor: changed skills locations --- .../skills/golang-expert/SKILL.md | 0 .../references/code-review-checklist.md | 0 .../golang-expert/references/concurrency.md | 0 .../references/error-handling.md | 0 .../references/functional-patterns.md | 0 .../references/interface-design.md | 0 .../golang-expert/references/kiss-dry.md | 0 .../golang-expert/references/performance.md | 0 .../golang-expert/references/testing.md | 0 AGENTS.md | 4 ++-- cli/root.go | 2 +- .../2026-02-04-skill-location-refactor.md | 19 +++++++++++++++++++ 12 files changed, 22 insertions(+), 3 deletions(-) rename {.github => .agents}/skills/golang-expert/SKILL.md (100%) rename {.github => .agents}/skills/golang-expert/references/code-review-checklist.md (100%) rename {.github => .agents}/skills/golang-expert/references/concurrency.md (100%) rename {.github => .agents}/skills/golang-expert/references/error-handling.md (100%) rename {.github => .agents}/skills/golang-expert/references/functional-patterns.md (100%) rename {.github => .agents}/skills/golang-expert/references/interface-design.md (100%) rename {.github => .agents}/skills/golang-expert/references/kiss-dry.md (100%) rename {.github => .agents}/skills/golang-expert/references/performance.md (100%) rename {.github => .agents}/skills/golang-expert/references/testing.md (100%) create mode 100644 docs/plans/jobs/2026-02-04-skill-location-refactor.md diff --git a/.github/skills/golang-expert/SKILL.md b/.agents/skills/golang-expert/SKILL.md similarity index 100% rename from .github/skills/golang-expert/SKILL.md rename to .agents/skills/golang-expert/SKILL.md diff --git a/.github/skills/golang-expert/references/code-review-checklist.md b/.agents/skills/golang-expert/references/code-review-checklist.md similarity index 100% rename from .github/skills/golang-expert/references/code-review-checklist.md rename to .agents/skills/golang-expert/references/code-review-checklist.md diff --git a/.github/skills/golang-expert/references/concurrency.md b/.agents/skills/golang-expert/references/concurrency.md similarity index 100% rename from .github/skills/golang-expert/references/concurrency.md rename to .agents/skills/golang-expert/references/concurrency.md diff --git a/.github/skills/golang-expert/references/error-handling.md b/.agents/skills/golang-expert/references/error-handling.md similarity index 100% rename from .github/skills/golang-expert/references/error-handling.md rename to .agents/skills/golang-expert/references/error-handling.md diff --git a/.github/skills/golang-expert/references/functional-patterns.md b/.agents/skills/golang-expert/references/functional-patterns.md similarity index 100% rename from .github/skills/golang-expert/references/functional-patterns.md rename to .agents/skills/golang-expert/references/functional-patterns.md diff --git a/.github/skills/golang-expert/references/interface-design.md b/.agents/skills/golang-expert/references/interface-design.md similarity index 100% rename from .github/skills/golang-expert/references/interface-design.md rename to .agents/skills/golang-expert/references/interface-design.md diff --git a/.github/skills/golang-expert/references/kiss-dry.md b/.agents/skills/golang-expert/references/kiss-dry.md similarity index 100% rename from .github/skills/golang-expert/references/kiss-dry.md rename to .agents/skills/golang-expert/references/kiss-dry.md diff --git a/.github/skills/golang-expert/references/performance.md b/.agents/skills/golang-expert/references/performance.md similarity index 100% rename from .github/skills/golang-expert/references/performance.md rename to .agents/skills/golang-expert/references/performance.md diff --git a/.github/skills/golang-expert/references/testing.md b/.agents/skills/golang-expert/references/testing.md similarity index 100% rename from .github/skills/golang-expert/references/testing.md rename to .agents/skills/golang-expert/references/testing.md diff --git a/AGENTS.md b/AGENTS.md index d30ee85..c4f0cc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,10 @@ This document provides essential context for any agent working in this repositor ## Skills -Use the following skill for this repository. Skills are defined under `.github/skills/*`. How they’re discovered/loaded depends on the agent tool. +Use the following skill for this repository. Skills are defined under `.agents/skills/*`. How they’re discovered/loaded depends on the agent tool. - `golang-expert` — apply for all Go code changes, refactors, reviews, testing, or Go best‑practice questions. - - Skill file: `.github/skills/golang-expert/SKILL.md` + - Skill file: `.agents/skills/golang-expert/SKILL.md` - Load only the relevant reference files from the skill when needed. --- diff --git a/cli/root.go b/cli/root.go index acd0f78..7737835 100644 --- a/cli/root.go +++ b/cli/root.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var Version = "0.1.0" +var Version = "0.1.1-alpha.0" var ( errCanceled = errors.New("git worktree task process canceled") diff --git a/docs/plans/jobs/2026-02-04-skill-location-refactor.md b/docs/plans/jobs/2026-02-04-skill-location-refactor.md new file mode 100644 index 0000000..b27c75b --- /dev/null +++ b/docs/plans/jobs/2026-02-04-skill-location-refactor.md @@ -0,0 +1,19 @@ +--- +title: "Codex Skill Path Refactor" +date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +Documented the refactor to align skill paths with Codex v0.94.0, moving references from `.codex/skills` to `.agents/skills`. + +## Changes +- Updated skill location references to the new `.agents/skills` path. +- Noted the change in repository documentation and context. + +## Rationale +Codex v0.94.0 now expects skills under `.agents/skills`, so references must match the new location to avoid broken lookups. + +## References +- [Codex v0.94.0 release](https://github.com/openai/codex/releases/tag/rust-v0.94.0) From 82544bb2113ee6ff62ea8026cf768917adcc30cf Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 11:21:55 +0800 Subject: [PATCH 02/19] docs(research): add research doc for Mode flag classic vs Codex --- ...search-2026-02-04-mode-classic-vs-codex.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/research-2026-02-04-mode-classic-vs-codex.md diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md new file mode 100644 index 0000000..a2dd86e --- /dev/null +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -0,0 +1,77 @@ +--- +title: "Mode Flag: classic vs codex" +date: 2026-02-04 +status: draft +agent: codex +--- + +## Goal +Define what a new global `--mode` flag should mean for this CLI, so we can support Codex App-style worktrees without breaking the current (“classic”) behavior. + +## Key Findings + +### Current CLI behavior (“classic”) +- **Create** couples “task” to both branch and path: + - Branch: `` (slugified). + - Path: `../_` (relative to repo root’s parent). +- **List/status/cleanup/finish** assume the above naming convention to map paths back to tasks. +- **Paths are displayed** relative to the repo root by default; `--abs`/`--absolute-path` shows absolute paths. + +### Codex App worktree behavior (“codex”) +Based on Codex App documentation, the worktree model is intentionally different from this CLI’s task/branch model: +- **Worktree location is not user-chosen**: worktrees are created under `$CODEX_HOME/worktrees` so the app can manage them consistently. +- **Worktrees start in detached HEAD** by default (to avoid Git’s restriction that a branch cannot be checked out in two worktrees at once). +- **Local changes may be applied** when the worktree is created from an existing local branch with uncommitted changes. +- **Sync is a first-class operation** for getting changes between the local checkout and the worktree: + - “Apply” worktree changes into local checkout. + - “Overwrite” worktree from local checkout. + - Sync does not transfer ignored files (and the resulting state may not match a full re-clone). +- **Worktree restoration** is a distinct concept (recreate a worktree from a Codex snapshot, rather than from the current local checkout). + +### Practical restrictions we likely need in `--mode=codex` +To avoid accidental behavior drift and to reflect the Codex App constraints, `codex` mode likely implies: +- **No implicit “task branch”**: default worktrees should be detached and may not have a stable `` branch name. +- **Different identity model**: a worktree might be identified by an ID (or metadata) rather than the `_` path convention. +- **Limited/changed support for merge flows**: + - `finish` (merge branch into target) only makes sense if a branch exists; detached worktrees need either (1) an explicit branch creation step or (2) a new “sync/apply” flow. +- **No arbitrary `--path` override** (or an explicit escape hatch), since Codex App doesn’t allow it and allowing it would complicate cleanup and display rules. + +### Path display differences (UX) +Codex App uses a “variable-aware” presentation of paths (and the user specifically called out `$CODEX_HOME`): +- In `codex` mode, prefer showing worktree paths under `$CODEX_HOME` as `$CODEX_HOME/...` rather than a long absolute path. +- In both modes, consider shortening the home directory to `~` (or `$HOME`) when rendering absolute paths. + +## Implications or Recommendations +- Add a global flag `--mode` with values: + - `classic` (default): current behavior and naming convention. + - `codex`: Codex App-aligned behavior (detached worktrees, `$CODEX_HOME` root, ID-based mapping, sync-oriented workflow). +- Treat `codex` mode as additive: + - Keep existing commands and semantics intact in `classic`. + - Introduce new behavior behind `--mode=codex` (and/or new codex-only subcommands like `sync`) rather than changing defaults. +- Define a “codex worktree root”: + - Required env var: `$CODEX_HOME` (error clearly if missing), or a documented default fallback (e.g., `~/.codex`) if we want to be permissive. +- Introduce a path rendering helper that can: + - Render relative-to-repo paths (classic default). + - Render `$CODEX_HOME`-relative paths (codex default). + - Still respect existing `--abs` behavior. + +## Open Questions +- **Identity & mapping:** How should `gwtt` map `` to a Codex-style worktree (ID, metadata file, a `.gwtt/` registry under `$CODEX_HOME`, etc.)? +- **Command support matrix:** Which commands should be supported in `codex` mode? + - `create`: detached by default? allow `--branch` to opt into a branch? + - `finish`: allowed only when branch exists? replaced by `sync apply`? + - `cleanup`: should it clean only `$CODEX_HOME/worktrees` entries or also classic paths? +- **Sync semantics in a CLI context:** Do we implement Codex-style “apply/overwrite” as: + - new `sync` command, or + - an option on existing commands (riskier for UX), or + - both (with `sync` as the primary entry point)? +- **Ignored files:** Do we explicitly document that ignored files are not synced in `codex` mode, and do we provide an opt-in mechanism (e.g. tar/rsync) or keep behavior aligned with Codex App? +- **Config & precedence:** Should mode be configurable via `GWTT_MODE` and `config.toml` (similar to theme), or remain flag-only to reduce ambiguity? + +## References +- Codex App worktrees documentation: https://developers.openai.com/codex/app/worktrees/ +- Git worktree manual: https://git-scm.com/docs/git-worktree + +## Related Plans +- (none) + From b87791d8f158d1d85df9d47f37a3e2d26ed5a70a Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 12:07:00 +0800 Subject: [PATCH 03/19] docs(research): add CLI alignment decisions for codex mode --- ...search-2026-02-04-mode-classic-vs-codex.md | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md index a2dd86e..52e697a 100644 --- a/docs/research-2026-02-04-mode-classic-vs-codex.md +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -1,7 +1,8 @@ --- title: "Mode Flag: classic vs codex" date: 2026-02-04 -status: draft +modified-date: 2026-02-04 +status: in-progress agent: codex --- @@ -28,13 +29,19 @@ Based on Codex App documentation, the worktree model is intentionally different - Sync does not transfer ignored files (and the resulting state may not match a full re-clone). - **Worktree restoration** is a distinct concept (recreate a worktree from a Codex snapshot, rather than from the current local checkout). -### Practical restrictions we likely need in `--mode=codex` -To avoid accidental behavior drift and to reflect the Codex App constraints, `codex` mode likely implies: -- **No implicit “task branch”**: default worktrees should be detached and may not have a stable `` branch name. -- **Different identity model**: a worktree might be identified by an ID (or metadata) rather than the `_` path convention. -- **Limited/changed support for merge flows**: - - `finish` (merge branch into target) only makes sense if a branch exists; detached worktrees need either (1) an explicit branch creation step or (2) a new “sync/apply” flow. -- **No arbitrary `--path` override** (or an explicit escape hatch), since Codex App doesn’t allow it and allowing it would complicate cleanup and display rules. +### Decisions for `--mode=codex` (CLI alignment) +To keep `classic` stable and keep `codex` aligned with Codex App: +- **Identity & mapping via registry (not per-worktree metadata files):** + - Store the minimal mapping needed to resolve ` -> worktree path` in a registry under `$CODEX_HOME` (rather than scattering metadata files inside worktrees). + - “Metadata” should remain derivable from the worktree itself (e.g., via `gwtt status`-equivalent logic). +- **Create is detached-only:** in `codex` mode, `create` should not offer a `--branch` escape hatch; the default stays detached to avoid future complexity. +- **Finish is classic-only:** in `codex` mode, `finish` is not a good fit; use a dedicated `sync` command instead. +- **Cleanup follows current mode:** `gwtt cleanup` should operate on the worktrees owned by the active mode (`classic` naming vs `$CODEX_HOME/worktrees` + registry), rather than mixing behaviors. + +### Practical restrictions implied by `--mode=codex` +- **No “task branch” assumption:** detached worktrees mean we can’t infer branch names from task names. +- **No arbitrary `--path` override:** codex-mode worktrees live under `$CODEX_HOME/worktrees`; allowing arbitrary paths would complicate cleanup, display, and registry invariants. +- **Different command surface:** branch-merge workflows (`finish`) are replaced by sync workflows (`sync apply` / `sync overwrite`). ### Path display differences (UX) Codex App uses a “variable-aware” presentation of paths (and the user specifically called out `$CODEX_HOME`): @@ -54,19 +61,21 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif - Render relative-to-repo paths (classic default). - Render `$CODEX_HOME`-relative paths (codex default). - Still respect existing `--abs` behavior. +- Implement `mode` as a first-class config value (not flag-only): + - Flag: `--mode` (highest precedence). + - Env var: `GWTT_MODE`. + - Config: `gwtt.config.toml`/`gwtt.toml` and `$HOME/.config/gwtt/config.toml`. + - Default: `classic`. +- Keep ignored-file behavior aligned with Codex App in `codex` mode (do not add “include ignored” options initially). ## Open Questions -- **Identity & mapping:** How should `gwtt` map `` to a Codex-style worktree (ID, metadata file, a `.gwtt/` registry under `$CODEX_HOME`, etc.)? -- **Command support matrix:** Which commands should be supported in `codex` mode? - - `create`: detached by default? allow `--branch` to opt into a branch? - - `finish`: allowed only when branch exists? replaced by `sync apply`? - - `cleanup`: should it clean only `$CODEX_HOME/worktrees` entries or also classic paths? -- **Sync semantics in a CLI context:** Do we implement Codex-style “apply/overwrite” as: - - new `sync` command, or - - an option on existing commands (riskier for UX), or - - both (with `sync` as the primary entry point)? -- **Ignored files:** Do we explicitly document that ignored files are not synced in `codex` mode, and do we provide an opt-in mechanism (e.g. tar/rsync) or keep behavior aligned with Codex App? -- **Config & precedence:** Should mode be configurable via `GWTT_MODE` and `config.toml` (similar to theme), or remain flag-only to reduce ambiguity? +- **Registry schema & location:** Where exactly under `$CODEX_HOME` should the registry live, and what format should it use? + - Example options: `$CODEX_HOME/gwtt/registry.json`, `$CODEX_HOME/gwtt/registry.toml`, or `$CODEX_HOME/gwtt/worktrees/registry.json`. + - Minimum recommended keys per entry: `task`, `repoRoot`, `worktreePath`, `createdAt`, and the “source ref” used to create it (branch/ref/commit). +- **Sync UX:** What should the CLI surface look like? + - `gwtt sync --apply|--overwrite` vs `gwtt sync apply ` / `gwtt sync overwrite `. + - Confirmations: treat “apply/overwrite” as a second, explicit confirmation (skippable with `--yes`), similar to the existing destructive confirmations pattern. +- **Restoration support:** Do we want a `restore` operation in the CLI (to mirror Codex App), or keep scope to create/sync/cleanup only? ## References - Codex App worktrees documentation: https://developers.openai.com/codex/app/worktrees/ @@ -74,4 +83,3 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif ## Related Plans - (none) - From b44293527dbf9703e17c1f91498af4a6324b6340 Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 14:24:50 +0800 Subject: [PATCH 04/19] docs(research): revise codex mode behavior and cleanup rules --- ...search-2026-02-04-mode-classic-vs-codex.md | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md index 52e697a..abc23e8 100644 --- a/docs/research-2026-02-04-mode-classic-vs-codex.md +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -20,7 +20,7 @@ Define what a new global `--mode` flag should mean for this CLI, so we can suppo ### Codex App worktree behavior (“codex”) Based on Codex App documentation, the worktree model is intentionally different from this CLI’s task/branch model: -- **Worktree location is not user-chosen**: worktrees are created under `$CODEX_HOME/worktrees` so the app can manage them consistently. +- **Worktree location is not per-worktree user-chosen**: worktrees are created under `$CODEX_HOME/worktrees` so the app can manage them consistently. - **Worktrees start in detached HEAD** by default (to avoid Git’s restriction that a branch cannot be checked out in two worktrees at once). - **Local changes may be applied** when the worktree is created from an existing local branch with uncommitted changes. - **Sync is a first-class operation** for getting changes between the local checkout and the worktree: @@ -28,20 +28,38 @@ Based on Codex App documentation, the worktree model is intentionally different - “Overwrite” worktree from local checkout. - Sync does not transfer ignored files (and the resulting state may not match a full re-clone). - **Worktree restoration** is a distinct concept (recreate a worktree from a Codex snapshot, rather than from the current local checkout). +- **Cleanup is app-governed and tied to threads**: the Codex app cleans up worktrees when you archive threads (or on startup for worktrees with no associated threads), and it preserves a snapshot for later restore. + +### Codex App FAQ takeaways (constraints we should mirror) +- Worktrees are created under `$CODEX_HOME/worktrees` so Codex can manage them consistently. +- Sessions cannot be moved between worktrees: to change environments, you start a new thread in the target environment and restate the prompt. +- Threads can remain even if the worktree directory is cleaned up; Codex snapshots work before cleanup and can offer restore when reopening the thread. ### Decisions for `--mode=codex` (CLI alignment) To keep `classic` stable and keep `codex` aligned with Codex App: -- **Identity & mapping via registry (not per-worktree metadata files):** - - Store the minimal mapping needed to resolve ` -> worktree path` in a registry under `$CODEX_HOME` (rather than scattering metadata files inside worktrees). - - “Metadata” should remain derivable from the worktree itself (e.g., via `gwtt status`-equivalent logic). +- **Identity & mapping via inspection (no extra registry files):** + - Do not introduce new state files like `registry.json`/TOML for codex mode. + - For `list`/`status`, inspect `$CODEX_HOME/worktrees/**` (and/or `git worktree list --porcelain` scoped to the local checkout) to discover worktrees and compute status. + - In codex mode, `` is the **exact** worktree directory name under `$CODEX_HOME/worktrees` (an opaque ID). - **Create is detached-only:** in `codex` mode, `create` should not offer a `--branch` escape hatch; the default stays detached to avoid future complexity. - **Finish is classic-only:** in `codex` mode, `finish` is not a good fit; use a dedicated `sync` command instead. -- **Cleanup follows current mode:** `gwtt cleanup` should operate on the worktrees owned by the active mode (`classic` naming vs `$CODEX_HOME/worktrees` + registry), rather than mixing behaviors. +- **Cleanup follows current mode:** `gwtt cleanup` should operate on the worktrees owned by the active mode (`classic` naming vs `$CODEX_HOME/worktrees`), rather than mixing behaviors. +- **Sync UX:** `gwtt sync ` defaults to “apply”; if a conflict is detected, prompt to “overwrite” (second confirmation), skippable with `--yes`. +- **Cleanup in codex mode:** free disk by deleting the on-disk directory under `$CODEX_HOME/worktrees/`, but only when it is safe: + - Skip worktrees that fall under Codex App’s “never clean up if …” restrictions. + - If we cannot verify a restriction (e.g., pinned/sidebar linkage), still allow deletion but show a prominent warning and require a second confirmation (skippable with `--yes`). + - Treat “Codex can restore later” as best-effort: Codex App snapshots before *its own* cleanup; `gwtt` cannot guarantee a snapshot exists before manual deletion. ### Practical restrictions implied by `--mode=codex` - **No “task branch” assumption:** detached worktrees mean we can’t infer branch names from task names. - **No arbitrary `--path` override:** codex-mode worktrees live under `$CODEX_HOME/worktrees`; allowing arbitrary paths would complicate cleanup, display, and registry invariants. - **Different command surface:** branch-merge workflows (`finish`) are replaced by sync workflows (`sync apply` / `sync overwrite`). +- **Cleanup restrictions from Codex App:** Codex App will not automatically clean up a worktree if: + - a pinned conversation is tied to it, + - it was added to the sidebar, + - it’s more than 4 days old, + - you have more than 10 worktrees. + - Note: the “more than 4 days old” / “more than 10 worktrees” conditions are counterintuitive, but this is the wording in the official docs as of 2026-02-04. ### Path display differences (UX) Codex App uses a “variable-aware” presentation of paths (and the user specifically called out `$CODEX_HOME`): @@ -56,7 +74,7 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif - Keep existing commands and semantics intact in `classic`. - Introduce new behavior behind `--mode=codex` (and/or new codex-only subcommands like `sync`) rather than changing defaults. - Define a “codex worktree root”: - - Required env var: `$CODEX_HOME` (error clearly if missing), or a documented default fallback (e.g., `~/.codex`) if we want to be permissive. + - In codex mode, treat `$CODEX_HOME/worktrees` as the only allowable root for “managed” worktrees. - Introduce a path rendering helper that can: - Render relative-to-repo paths (classic default). - Render `$CODEX_HOME`-relative paths (codex default). @@ -67,18 +85,28 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif - Config: `gwtt.config.toml`/`gwtt.toml` and `$HOME/.config/gwtt/config.toml`. - Default: `classic`. - Keep ignored-file behavior aligned with Codex App in `codex` mode (do not add “include ignored” options initially). +- Codex-mode cleanup should be **disk-focused and conservative**: + - Only target paths under `$CODEX_HOME/worktrees/` (no arbitrary deletion). + - Attempt to mirror Codex App’s “never clean up if …” rules; if we cannot verify a rule (e.g., pinned/sidebar linkage), show a prominent warning and require a second confirmation (skippable with `--yes`). +- Repo scoping in codex mode should come from Git, not naming: + - To list/status only the Codex worktrees for the *current repo*, run `git -C worktree list --porcelain` and include only entries whose `worktree` path is under `$CODEX_HOME/worktrees/`. + - This avoids relying on the opaque directory name to encode repo identity. +- Consider adding `modified_time` to `status` (and optionally `list`) rows: + - Use the filesystem `mtime` of the worktree directory as a pragmatic “last touched” signal. + - Output format recommendation: RFC3339 in UTC for JSON/CSV; table output can display the same value (no additional config initially). ## Open Questions -- **Registry schema & location:** Where exactly under `$CODEX_HOME` should the registry live, and what format should it use? - - Example options: `$CODEX_HOME/gwtt/registry.json`, `$CODEX_HOME/gwtt/registry.toml`, or `$CODEX_HOME/gwtt/worktrees/registry.json`. - - Minimum recommended keys per entry: `task`, `repoRoot`, `worktreePath`, `createdAt`, and the “source ref” used to create it (branch/ref/commit). -- **Sync UX:** What should the CLI surface look like? - - `gwtt sync --apply|--overwrite` vs `gwtt sync apply ` / `gwtt sync overwrite `. - - Confirmations: treat “apply/overwrite” as a second, explicit confirmation (skippable with `--yes`), similar to the existing destructive confirmations pattern. -- **Restoration support:** Do we want a `restore` operation in the CLI (to mirror Codex App), or keep scope to create/sync/cleanup only? +- **Sync conflict detection:** Use predictable signals: + - Local checkout is dirty. + - Patch/apply/merge step fails. + - Both sides modified the same file (where we can detect it). +- **Cleanup restrictions detection:** We likely cannot reliably detect “pinned” / “sidebar” / thread linkage without reading Codex App state. + - Decision: allow deletion with warnings + second confirmation (and keep deletion narrowly scoped to `$CODEX_HOME/worktrees/`). +- **Restoration support:** Keep scope to create/sync/list/status only for now. + - Open: if we ever add `restore`, it likely needs to integrate with Codex App’s snapshot state (i.e., app-owned data). Without that, `gwtt` can only delete disk folders and let the app restore opportunistically. ## References -- Codex App worktrees documentation: https://developers.openai.com/codex/app/worktrees/ +- Codex App worktrees documentation (includes cleanup + FAQ): https://developers.openai.com/codex/app/worktrees/ - Git worktree manual: https://git-scm.com/docs/git-worktree ## Related Plans From e0b4aead199810ca1872463646b98949bd13c2d8 Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 14:39:48 +0800 Subject: [PATCH 05/19] docs: add Mode Flag Plan for Classic and Codex --- .../plan-2026-02-04-mode-classic-and-codex.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/plans/plan-2026-02-04-mode-classic-and-codex.md diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md new file mode 100644 index 0000000..b5ffe93 --- /dev/null +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -0,0 +1,94 @@ +--- +title: "Mode flag: classic and codex" +date: 2026-02-04 +status: active +agent: codex +--- + +## Goal +Add a new global `--mode` (`classic` default, `codex` optional) to support Codex App-style worktrees while keeping current behavior stable and non-breaking. + +## Scope +- `--mode` flag + `GWTT_MODE` env + config default (flag > env > config > default). +- Codex-mode worktree discovery under `$CODEX_HOME/worktrees/**` with repo-scoped filtering via `git worktree list --porcelain`. +- Codex-mode selection model: `` is the exact `` directory name under `$CODEX_HOME/worktrees`. +- Codex-mode `sync` command (`apply` by default; optional overwrite on conflict with a second confirmation). +- Codex-mode `cleanup` that only targets `$CODEX_HOME/worktrees/` and is conservative with prominent warnings + confirmations. +- Add `modified_time` to `status` output (RFC3339 UTC). + +## Non-Goals +- No registry/state files (no `registry.json` / extra TOML) for codex mode. +- No branch-based workflows in codex mode (no `finish` in codex mode; no `create --branch` in codex mode). +- No `restore` command in this phase. + +## Plan +### Phase 1: Docs & Spec Design +- [ ] Update `docs/research-2026-02-04-mode-classic-vs-codex.md` to reflect final decisions as implementation progresses. +- [ ] Update `docs/schemas/config-gwtt.md` to include `mode` and env var `GWTT_MODE`. +- [ ] Write a short CLI spec section (either in the research doc or a new schema doc) covering: + - [ ] Codex-mode worktree selection: `` resolution rules and error messages. + - [ ] Repo scoping strategy for `list/status` in codex mode (Git-derived, not naming-derived). + - [ ] `sync` conflict detection signals and the overwrite confirmation flow (`--yes` behavior). + - [ ] `cleanup` safety model (scope restriction, warnings, second confirmation, and “restore is best-effort” note). + +### Phase 2: Code Implementation +- [ ] Add global `--mode` persistent flag on `cli/root.go` and plumb mode into command execution (context/config). +- [ ] Add mode to config resolution: + - [ ] Env: `GWTT_MODE`. + - [ ] Config: `mode = "classic"|"codex"`. + - [ ] Validation and error messaging for unsupported values. +- [ ] Codex-mode worktree discovery primitives: + - [ ] Determine `$CODEX_HOME` (or `CODEX_HOME`) and `worktreesRoot := $CODEX_HOME/worktrees`. + - [ ] Use `internal/worktree.List(ctx, runner, repoRoot)` and filter entries whose path is under `worktreesRoot`. + - [ ] Derive `` as `filepath.Base(worktreePath)`. +- [ ] Implement codex-mode behavior for read-only commands: + - [ ] `gwtt list` shows codex worktrees for the current repo with `Task=` and a `$CODEX_HOME/...`-aware display path. + - [ ] `gwtt status` does the same, plus `modified_time`. +- [ ] Add `modified_time` to status rows: + - [ ] Use filesystem `mtime` of the worktree directory. + - [ ] Format as RFC3339 UTC for JSON/CSV; table uses the same value. +- [ ] Add `gwtt sync ` (codex-mode only): + - [ ] Default to “apply” (worktree -> local checkout). + - [ ] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). + - [ ] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. + - [ ] Keep behavior aligned with Codex App: ignored files are not synced. +- [ ] Re-check `cleanup` behavior for codex mode: + - [ ] Restrict deletions to `$CODEX_HOME/worktrees/` only. + - [ ] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. + - [ ] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). + +### Phase 3: Unit Test Verification +- [ ] Add tests for `mode` precedence and validation (flag/env/config/default). +- [ ] Add tests for codex-mode list/status filtering (repo-scoped via `git worktree list` + `$CODEX_HOME/worktrees` prefix filter). +- [ ] Add tests for `` derivation and path rendering (`$CODEX_HOME` display). +- [ ] Add tests for `modified_time` formatting (RFC3339 UTC) and JSON/CSV output shape. +- [ ] Add tests for `sync` conflict detection and confirmation gating (including `--yes`). +- [ ] Add tests for codex cleanup scope restriction + confirmation flow. + +### Phase 4: README / CLI Docs Update +- [ ] Update `README.md`: + - [ ] Document `--mode`, `GWTT_MODE`, and config `mode`. + - [ ] Add codex-mode usage examples for `list/status/sync/cleanup`. + - [ ] Update `## Notes` “Global flags” list to include `--mode`. + - [ ] Document `modified_time` in `status` outputs (and the fixed date format). +- [ ] If applicable, update any man/help text sources under `man/` to reflect new commands/flags. + +### Phase 5: Verify Doc Statuses +- [ ] Ensure this plan’s `status` matches the actual phase progress (`active` -> `completed` when done). +- [ ] Update the research doc’s `status` to `completed` once decisions are implemented and verified. +- [ ] Ensure any schema/doc updates have consistent status and dates (`modified-date` as needed). + +## Acceptance Criteria +- Default behavior (no `--mode`, no `GWTT_MODE`, no config) remains unchanged. +- `--mode=codex` enables codex-specific list/status/sync/cleanup without impacting classic users. +- Codex-mode selection uses `` reliably and errors clearly when not found/ambiguous. +- `status` includes `modified_time` with RFC3339 UTC formatting for machine outputs. +- Codex-mode cleanup is narrowly scoped and always warns + confirms before deletion. + +## Risks / Notes +- Codex App “pinned/sidebar/thread linkage” signals may not be detectable from disk without reading Codex’s internal state; default to warnings + a second confirmation when uncertain. +- `sync` semantics are easy to get subtly wrong; keep the initial implementation conservative and well-tested. + +## Related Research +- `docs/research-2026-02-04-mode-classic-vs-codex.md` + From 19a47ea14acff9d988bd25c1c5a0eee5c03b8154 Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 14:52:02 +0800 Subject: [PATCH 06/19] docs: add Codex mode CLI spec and update config schema --- .../plan-2026-02-04-mode-classic-and-codex.md | 15 ++-- ...search-2026-02-04-mode-classic-vs-codex.md | 68 ++++++++++++++----- docs/schemas/config-gwtt.md | 16 ++++- 3 files changed, 74 insertions(+), 25 deletions(-) diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md index b5ffe93..fd9d1a4 100644 --- a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -23,13 +23,13 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex ## Plan ### Phase 1: Docs & Spec Design -- [ ] Update `docs/research-2026-02-04-mode-classic-vs-codex.md` to reflect final decisions as implementation progresses. -- [ ] Update `docs/schemas/config-gwtt.md` to include `mode` and env var `GWTT_MODE`. -- [ ] Write a short CLI spec section (either in the research doc or a new schema doc) covering: - - [ ] Codex-mode worktree selection: `` resolution rules and error messages. - - [ ] Repo scoping strategy for `list/status` in codex mode (Git-derived, not naming-derived). - - [ ] `sync` conflict detection signals and the overwrite confirmation flow (`--yes` behavior). - - [ ] `cleanup` safety model (scope restriction, warnings, second confirmation, and “restore is best-effort” note). +- [x] Update `docs/research-2026-02-04-mode-classic-vs-codex.md` to reflect final decisions as implementation progresses. +- [x] Update `docs/schemas/config-gwtt.md` to include `mode` and env var `GWTT_MODE`. +- [x] Write a short CLI spec section (either in the research doc or a new schema doc) covering: + - [x] Codex-mode worktree selection: `` resolution rules and error messages. + - [x] Repo scoping strategy for `list/status` in codex mode (Git-derived, not naming-derived). + - [x] `sync` conflict detection signals and the overwrite confirmation flow (`--yes` behavior). + - [x] `cleanup` safety model (scope restriction, warnings, second confirmation, and “restore is best-effort” note). ### Phase 2: Code Implementation - [ ] Add global `--mode` persistent flag on `cli/root.go` and plumb mode into command execution (context/config). @@ -91,4 +91,3 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex ## Related Research - `docs/research-2026-02-04-mode-classic-vs-codex.md` - diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md index abc23e8..6074020 100644 --- a/docs/research-2026-02-04-mode-classic-vs-codex.md +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -54,12 +54,8 @@ To keep `classic` stable and keep `codex` aligned with Codex App: - **No “task branch” assumption:** detached worktrees mean we can’t infer branch names from task names. - **No arbitrary `--path` override:** codex-mode worktrees live under `$CODEX_HOME/worktrees`; allowing arbitrary paths would complicate cleanup, display, and registry invariants. - **Different command surface:** branch-merge workflows (`finish`) are replaced by sync workflows (`sync apply` / `sync overwrite`). -- **Cleanup restrictions from Codex App:** Codex App will not automatically clean up a worktree if: - - a pinned conversation is tied to it, - - it was added to the sidebar, - - it’s more than 4 days old, - - you have more than 10 worktrees. - - Note: the “more than 4 days old” / “more than 10 worktrees” conditions are counterintuitive, but this is the wording in the official docs as of 2026-02-04. +- **Cleanup restrictions from Codex App:** Codex App’s auto-cleanup is disabled in some cases (e.g., pinned conversation, added to sidebar, age > 4 days, worktree count > 10). + - Note: the “age > 4 days” / “count > 10” conditions are counterintuitive, but this is the wording in the official docs as of 2026-02-04. ### Path display differences (UX) Codex App uses a “variable-aware” presentation of paths (and the user specifically called out `$CODEX_HOME`): @@ -95,19 +91,59 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif - Use the filesystem `mtime` of the worktree directory as a pragmatic “last touched” signal. - Output format recommendation: RFC3339 in UTC for JSON/CSV; table output can display the same value (no additional config initially). -## Open Questions -- **Sync conflict detection:** Use predictable signals: - - Local checkout is dirty. - - Patch/apply/merge step fails. - - Both sides modified the same file (where we can detect it). -- **Cleanup restrictions detection:** We likely cannot reliably detect “pinned” / “sidebar” / thread linkage without reading Codex App state. - - Decision: allow deletion with warnings + second confirmation (and keep deletion narrowly scoped to `$CODEX_HOME/worktrees/`). -- **Restoration support:** Keep scope to create/sync/list/status only for now. - - Open: if we ever add `restore`, it likely needs to integrate with Codex App’s snapshot state (i.e., app-owned data). Without that, `gwtt` can only delete disk folders and let the app restore opportunistically. +## CLI Spec (Draft) + +### Mode resolution +- Flag: `--mode` (`classic` or `codex`). +- Env: `GWTT_MODE`. +- Config: top-level `mode = "classic"|"codex"`. +- Default: `classic`. + +### Codex home + worktrees root +- Resolve Codex home from the `CODEX_HOME` env var; if unset, default to `~/.codex` (Codex App default). +- Managed codex-mode worktrees are always under `$CODEX_HOME/worktrees/`. +- Display paths under this root as `$CODEX_HOME/...` by default (unless `--abs` forces absolute). + +### Selection model (`` in codex mode) +- `` is the exact `` directory name under `$CODEX_HOME/worktrees`. +- `gwtt list/status --mode=codex` should render `TASK=` to make the identifier discoverable and copy/paste friendly. + +### Repo scoping (`list/status` in codex mode) +- Use Git as the source of truth for “worktrees belonging to this repo”: + - Run `git -C worktree list --porcelain`. + - Filter entries whose worktree path is under `$CODEX_HOME/worktrees/`. +- Do not attempt to infer repo identity from `` naming. + +### `sync` (codex mode) +- CLI: `gwtt sync ` (default operation: apply worktree changes into the local checkout). +- Conflict detection signals (predictable, conservative): + - Local checkout is dirty, or + - the apply/merge step fails, or + - both sides modified the same file (where detectable). +- On conflict: prompt whether to “overwrite” (local -> worktree) and require a second confirmation; `--yes` bypasses the overwrite confirmation. +- Keep Codex App parity: ignored files are not synced. + +### `cleanup` (codex mode) +- Scope: only delete the on-disk directory at `$CODEX_HOME/worktrees/` (free disk; do not touch classic paths). +- Since pinned/sidebar/thread linkage is not reliably detectable without reading Codex App state: + - Always show a prominent warning (“may break pinned/sidebar restore expectations”) and require an extra confirmation (skippable with `--yes`). + - Treat restore as best-effort: `gwtt` cannot guarantee a snapshot exists before manual deletion. + +### `status` metadata addition: `modified_time` +- Add `modified_time` derived from filesystem `mtime` of the worktree directory. +- Format: RFC3339 UTC for JSON/CSV; table output prints the same string. +- No date-format config in the first iteration. + +## Open Questions (Remaining) +- Can we (safely) detect any Codex cleanup-restriction signals from disk without coupling `gwtt` to Codex’s internal storage formats? +- What is the most user-friendly confirmation wording for “overwrite” (sync) and “yolo delete” (cleanup) that still prevents accidents? + +## Notes +- Restoration remains out of scope: keep to create/sync/list/status only for now; a future `restore` likely needs to integrate with Codex App snapshot state. ## References - Codex App worktrees documentation (includes cleanup + FAQ): https://developers.openai.com/codex/app/worktrees/ - Git worktree manual: https://git-scm.com/docs/git-worktree ## Related Plans -- (none) +- `docs/plans/plan-2026-02-04-mode-classic-and-codex.md` diff --git a/docs/schemas/config-gwtt.md b/docs/schemas/config-gwtt.md index 9d4618c..7c1d20a 100644 --- a/docs/schemas/config-gwtt.md +++ b/docs/schemas/config-gwtt.md @@ -1,7 +1,8 @@ --- title: "gwtt configuration schema" date: 2026-01-27 -status: completed +modified-date: 2026-02-04 +status: in-progress agent: codex --- @@ -15,7 +16,18 @@ Define the authoritative configuration schema for `gwtt`, including keys, types, 4. User config (`$HOME/.config/gwtt/config.toml`) 5. Built-in defaults +## Environment variables +- `GWTT_THEME` overrides `[theme].name`. +- `GWTT_COLOR` overrides `[ui].color_enabled`. +- `GWTT_MODE` overrides `mode`. +- `CODEX_HOME` is consumed in `mode="codex"` to locate `$CODEX_HOME/worktrees` (this is a Codex App/Codex CLI convention, not a `gwtt` config key). + - Fallback: if `CODEX_HOME` is unset, `gwtt` should assume `~/.codex` (home dir + `/.codex`) to align with Codex defaults. + - Note: confirm the default path against current Codex App/Codex CLI docs when implementing (and prefer matching their behavior over introducing a new `gwtt`-specific default). + ## Schema +### Root +- `mode` (string enum: `classic`, `codex`; default: `classic`) + ### `[theme]` - `name` (string, default: `"default"`) @@ -79,6 +91,8 @@ Define the authoritative configuration schema for `gwtt`, including keys, types, ## Examples ```toml +mode = "classic" + [theme] name = "nord" From b7657965a4eb53766ccfa35759e49f6947da196e Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 15:17:59 +0800 Subject: [PATCH 07/19] feat: add Codex mode and sync command --- cli/cleanup.go | 117 +++-- cli/codex.go | 42 ++ cli/common.go | 39 ++ cli/create.go | 3 + cli/finish.go | 3 + cli/list.go | 62 ++- cli/mode.go | 24 + cli/root.go | 12 + cli/status.go | 163 +++++-- cli/sync.go | 411 ++++++++++++++++++ .../2026-02-04-implement-mode-flag-phase2.md | 18 + .../plan-2026-02-04-mode-classic-and-codex.md | 48 +- internal/config/config.go | 10 + 13 files changed, 850 insertions(+), 102 deletions(-) create mode 100644 cli/codex.go create mode 100644 cli/mode.go create mode 100644 cli/sync.go create mode 100644 docs/plans/jobs/2026-02-04-implement-mode-flag-phase2.md diff --git a/cli/cleanup.go b/cli/cleanup.go index 51a67b1..08fa22c 100644 --- a/cli/cleanup.go +++ b/cli/cleanup.go @@ -2,6 +2,8 @@ package cli import ( "fmt" + "path/filepath" + "strings" "github.com/pi2pie/git-worktree-tasks/internal/git" "github.com/pi2pie/git-worktree-tasks/internal/worktree" @@ -28,7 +30,19 @@ func newCleanupCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() runner := defaultRunner() + mode := modeClassic + var codexHome string + var codexWorktrees string if cfg, ok := configFromContext(cmd.Context()); ok { + mode = cfg.Mode + if mode == modeCodex { + var err error + codexHome, err = codexHomeDir() + if err != nil { + return err + } + codexWorktrees = codexWorktreesRoot(codexHome) + } if !cmd.Flags().Changed("yes") { opts.yes = !cfg.Cleanup.Confirm } @@ -56,15 +70,30 @@ func newCleanupCommand() *cobra.Command { if err != nil { return err } - task := worktree.SlugifyTask(args[0]) - path := worktree.WorktreePath(repoRoot, repo, task) - branch := task + task := args[0] + if mode != modeCodex { + task = worktree.SlugifyTask(args[0]) + } + path := "" + branch := "" + if mode != modeCodex { + path = worktree.WorktreePath(repoRoot, repo, task) + branch = task + } if opts.worktreeOnly { opts.removeWorktree = true opts.removeBranch = false } + if mode == modeCodex { + if cmd.Flags().Changed("remove-branch") && opts.removeBranch { + return fmt.Errorf("branch cleanup is not supported in --mode=codex") + } + opts.removeBranch = false + opts.forceBranch = false + } + if !opts.removeWorktree && !opts.removeBranch { return fmt.Errorf("nothing to clean: enable --remove-worktree and/or --remove-branch") } @@ -76,40 +105,62 @@ func newCleanupCommand() *cobra.Command { resolvedPath := path worktreeExists := false - branchRef := "refs/heads/" + branch - repoRootPath, err := worktree.NormalizePath(repoRoot, repoRoot) - if err != nil { - return err - } - for _, wt := range worktrees { - if wt.Branch != branchRef { - continue - } - wtPath, err := worktree.NormalizePath(repoRoot, wt.Path) - if err != nil { - return err + if mode == modeCodex { + query := strings.TrimSpace(task) + if query == "" { + return fmt.Errorf("task query cannot be empty") } - if wtPath == repoRootPath { - continue + for _, wt := range worktrees { + wtAbs, err := worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + if !isUnderDir(codexWorktrees, wtAbs) { + continue + } + if filepath.Base(wtAbs) != query { + continue + } + resolvedPath = wt.Path + worktreeExists = true + break } - resolvedPath = wt.Path - worktreeExists = true - break - } - - if !worktreeExists { - targetPath, err := worktree.NormalizePath(repoRoot, path) + } else { + branchRef := "refs/heads/" + branch + repoRootPath, err := worktree.NormalizePath(repoRoot, repoRoot) if err != nil { return err } for _, wt := range worktrees { + if wt.Branch != branchRef { + continue + } wtPath, err := worktree.NormalizePath(repoRoot, wt.Path) if err != nil { return err } - if wtPath == targetPath { - worktreeExists = true - break + if wtPath == repoRootPath { + continue + } + resolvedPath = wt.Path + worktreeExists = true + break + } + + if !worktreeExists { + targetPath, err := worktree.NormalizePath(repoRoot, path) + if err != nil { + return err + } + for _, wt := range worktrees { + wtPath, err := worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + if wtPath == targetPath { + worktreeExists = true + break + } } } } @@ -155,6 +206,18 @@ func newCleanupCommand() *cobra.Command { if !ok { return errCanceled } + if mode == modeCodex { + if _, err := fmt.Fprintln(cmd.OutOrStdout(), ui.WarningStyle.Render("warning: codex-mode deletion cannot verify pinned/sidebar/thread linkage; restore is best-effort")); err != nil { + return err + } + ok, err := confirmPrompt(cmd.InOrStdin(), cmd.OutOrStdout(), "Remove Codex worktree anyway?") + if err != nil { + return err + } + if !ok { + return errCanceled + } + } } if err := runGit(ctx, cmd, opts.dryRun, runner, "-C", repoRoot, "worktree", "remove", resolvedPath); err != nil { return err diff --git a/cli/codex.go b/cli/codex.go new file mode 100644 index 0000000..bd754d1 --- /dev/null +++ b/cli/codex.go @@ -0,0 +1,42 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func codexHomeDir() (string, error) { + if raw, ok := os.LookupEnv("CODEX_HOME"); ok { + value := strings.TrimSpace(raw) + if value != "" { + if strings.HasPrefix(value, "~") { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "", fmt.Errorf("resolve CODEX_HOME: %w", err) + } + if value == "~" { + value = home + } else if strings.HasPrefix(value, "~/") || strings.HasPrefix(value, `~\`) { + value = filepath.Join(home, value[2:]) + } + } + abs, err := filepath.Abs(value) + if err != nil { + return "", fmt.Errorf("resolve CODEX_HOME: %w", err) + } + return filepath.Clean(abs), nil + } + } + + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "", fmt.Errorf("resolve CODEX_HOME: %w", err) + } + return filepath.Join(home, ".codex"), nil +} + +func codexWorktreesRoot(codexHome string) string { + return filepath.Join(codexHome, "worktrees") +} diff --git a/cli/common.go b/cli/common.go index b6b5d76..b5c791f 100644 --- a/cli/common.go +++ b/cli/common.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "os" "path/filepath" "strings" @@ -35,6 +36,44 @@ func displayPath(repoRoot, path string, absolute bool) string { return rel } +func displayPathForMode(repoRoot, path string, absolute bool, mode string, codexHome string) string { + if absolute { + return displayPath(repoRoot, path, true) + } + if mode != modeCodex { + return displayPath(repoRoot, path, false) + } + if strings.TrimSpace(codexHome) == "" { + return displayPath(repoRoot, path, false) + } + absPath, err := worktree.NormalizePath(repoRoot, path) + if err != nil { + return displayPath(repoRoot, path, false) + } + codexHomeAbs := filepath.Clean(codexHome) + if !isUnderDir(codexHomeAbs, absPath) { + return displayPath(repoRoot, absPath, false) + } + rel, err := filepath.Rel(codexHomeAbs, absPath) + if err != nil { + return displayPath(repoRoot, absPath, false) + } + return filepath.Join("$CODEX_HOME", rel) +} + +func isUnderDir(root, path string) bool { + root = filepath.Clean(root) + path = filepath.Clean(path) + if path == root { + return true + } + sep := string(os.PathSeparator) + if !strings.HasSuffix(root, sep) { + root += sep + } + return strings.HasPrefix(path, root) +} + func mainWorktreePathFromCommonDir(repoRoot, commonDir string) string { if commonDir == "" { return repoRoot diff --git a/cli/create.go b/cli/create.go index 49f55ed..0e2e565 100644 --- a/cli/create.go +++ b/cli/create.go @@ -30,6 +30,9 @@ func newCreateCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() runner := defaultRunner() + if cfg, ok := configFromContext(ctx); ok && cfg.Mode == modeCodex { + return fmt.Errorf("create is not supported in --mode=codex yet (use Codex App to create worktrees or run with --mode=classic)") + } repoRoot, err := repoRoot(ctx, runner) if err != nil { diff --git a/cli/finish.go b/cli/finish.go index 8574466..bc0af53 100644 --- a/cli/finish.go +++ b/cli/finish.go @@ -33,6 +33,9 @@ func newFinishCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() runner := defaultRunner() + if cfg, ok := configFromContext(ctx); ok && cfg.Mode == modeCodex { + return fmt.Errorf("finish is not supported in --mode=codex (use gwtt sync or run with --mode=classic)") + } if cfg, ok := configFromContext(cmd.Context()); ok { if !cmd.Flags().Changed("yes") { opts.yes = !cfg.Finish.Confirm diff --git a/cli/list.go b/cli/list.go index c0afeaf..50124bc 100644 --- a/cli/list.go +++ b/cli/list.go @@ -4,6 +4,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "path/filepath" "strconv" "strings" @@ -41,7 +42,19 @@ func newListCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() runner := defaultRunner() + mode := modeClassic + var codexHome string + var codexWorktrees string if cfg, ok := configFromContext(cmd.Context()); ok { + mode = cfg.Mode + if mode == modeCodex { + var err error + codexHome, err = codexHomeDir() + if err != nil { + return err + } + codexWorktrees = codexWorktreesRoot(codexHome) + } if !cmd.Flags().Changed("output") { opts.output = cfg.List.Output } @@ -71,9 +84,17 @@ func newListCommand() *cobra.Command { } var query string if len(args) == 1 { - query, err = normalizeTaskQuery(args[0]) - if err != nil { - return err + if mode == modeCodex { + query = strings.TrimSpace(args[0]) + if query == "" { + return fmt.Errorf("task query cannot be empty") + } + opts.strict = true + } else { + query, err = normalizeTaskQuery(args[0]) + if err != nil { + return err + } } } if opts.output == "raw" && query == "" && opts.branch == "" { @@ -96,19 +117,40 @@ func newListCommand() *cobra.Command { rows := make([]listRow, 0, len(worktrees)) for _, wt := range worktrees { branch := strings.TrimPrefix(wt.Branch, "refs/heads/") - task, _ := worktree.TaskFromPath(repo, wt.Path) - if task == "" { - task = "-" + task := "-" + if mode == modeCodex { + wtAbs, err := worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + if !isUnderDir(codexWorktrees, wtAbs) { + continue + } + task = filepath.Base(wtAbs) + if branch == "" { + branch = "detached" + } + } else { + task, _ = worktree.TaskFromPath(repo, wt.Path) + if task == "" { + task = "-" + } } row := listRow{ Task: task, Branch: branch, - Path: displayPath(repoRoot, wt.Path, opts.abs), + Path: displayPathForMode(repoRoot, wt.Path, opts.abs, mode, codexHome), Present: true, Head: worktree.ShortHash(wt.Head, shortHashLen), } - if query != "" && !matchesTask(row.Task, query, opts.strict) { - continue + if query != "" { + if mode == modeCodex { + if row.Task != query { + continue + } + } else if !matchesTask(row.Task, query, opts.strict) { + continue + } } if opts.branch != "" && row.Branch != opts.branch { continue @@ -116,7 +158,7 @@ func newListCommand() *cobra.Command { rows = append(rows, row) } - if opts.output == "raw" && len(rows) == 0 { + if mode != modeCodex && opts.output == "raw" && len(rows) == 0 { fallbackBranch := opts.branch if fallbackBranch == "" { fallbackBranch = query diff --git a/cli/mode.go b/cli/mode.go new file mode 100644 index 0000000..2dfd3cb --- /dev/null +++ b/cli/mode.go @@ -0,0 +1,24 @@ +package cli + +import ( + "fmt" + "strings" +) + +const ( + modeClassic = "classic" + modeCodex = "codex" +) + +func normalizeMode(raw string) (string, error) { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return modeClassic, nil + } + switch value { + case modeClassic, modeCodex: + return value, nil + default: + return "", fmt.Errorf("unsupported mode %q (use classic or codex)", raw) + } +} diff --git a/cli/root.go b/cli/root.go index 7737835..22b54ad 100644 --- a/cli/root.go +++ b/cli/root.go @@ -47,6 +47,7 @@ type runState struct { exitOnWarning bool noColor bool theme string + mode string listThemes bool } @@ -76,6 +77,7 @@ func gitWorkTreeCommand() (*cobra.Command, *runState) { cmd.SetErr(os.Stderr) cmd.PersistentFlags().BoolVar(&state.noColor, "nocolor", false, "disable color output") cmd.PersistentFlags().StringVar(&state.theme, "theme", ui.DefaultThemeName(), "color theme: "+strings.Join(ui.ThemeNames(), ", ")) + cmd.PersistentFlags().StringVar(&state.mode, "mode", "classic", "execution mode: classic or codex") cmd.PersistentFlags().BoolVar(&state.listThemes, "themes", false, "print available themes and exit") cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if state.listThemes { @@ -88,6 +90,15 @@ func gitWorkTreeCommand() (*cobra.Command, *runState) { if err != nil { return err } + mode := cfg.Mode + if cmd.Flags().Changed("mode") { + mode = state.mode + } + mode, err = normalizeMode(mode) + if err != nil { + return err + } + cfg.Mode = mode cmd.SetContext(withConfig(cmd.Context(), &cfg)) themeName := state.theme if !cmd.Flags().Changed("theme") { @@ -112,6 +123,7 @@ func gitWorkTreeCommand() (*cobra.Command, *runState) { newCleanupCommand(), newListCommand(), newStatusCommand(), + newSyncCommand(), newTUICommand(), ) diff --git a/cli/status.go b/cli/status.go index 2285d15..09f80d0 100644 --- a/cli/status.go +++ b/cli/status.go @@ -5,8 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "os" + "path/filepath" "strconv" "strings" + "time" "github.com/charmbracelet/lipgloss" "github.com/pi2pie/git-worktree-tasks/internal/git" @@ -26,15 +29,16 @@ type statusOptions struct { } type statusRow struct { - Task string `json:"task"` - Branch string `json:"branch"` - Path string `json:"path"` - Base string `json:"base"` - Target string `json:"target"` - LastCommit string `json:"last_commit"` - Dirty bool `json:"dirty"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` + Task string `json:"task"` + Branch string `json:"branch"` + Path string `json:"path"` + ModifiedTime string `json:"modified_time"` + Base string `json:"base"` + Target string `json:"target"` + LastCommit string `json:"last_commit"` + Dirty bool `json:"dirty"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` } func newStatusCommand() *cobra.Command { @@ -46,7 +50,19 @@ func newStatusCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() runner := defaultRunner() + mode := modeClassic + var codexHome string + var codexWorktrees string if cfg, ok := configFromContext(cmd.Context()); ok { + mode = cfg.Mode + if mode == modeCodex { + var err error + codexHome, err = codexHomeDir() + if err != nil { + return err + } + codexWorktrees = codexWorktreesRoot(codexHome) + } if !cmd.Flags().Changed("output") { opts.output = cfg.Status.Output } @@ -73,15 +89,31 @@ func newStatusCommand() *cobra.Command { } var query string if len(args) == 1 { - query, err = normalizeTaskQuery(args[0]) - if err != nil { - return err + if mode == modeCodex { + query = strings.TrimSpace(args[0]) + if query == "" { + return fmt.Errorf("task query cannot be empty") + } + opts.strict = true + } else { + query, err = normalizeTaskQuery(args[0]) + if err != nil { + return err + } } } if opts.task != "" { - query, err = normalizeTaskQuery(opts.task) - if err != nil { - return err + if mode == modeCodex { + query = strings.TrimSpace(opts.task) + if query == "" { + return fmt.Errorf("task query cannot be empty") + } + } else { + query, err = normalizeTaskQuery(opts.task) + if err != nil { + return err + } + opts.strict = true } opts.strict = true } @@ -108,12 +140,32 @@ func newStatusCommand() *cobra.Command { rows := make([]statusRow, 0, len(worktrees)) for _, wt := range worktrees { branch := strings.TrimPrefix(wt.Branch, "refs/heads/") - task, _ := worktree.TaskFromPath(repo, wt.Path) - if task == "" { - task = "-" - } - if query != "" && !matchesTask(task, query, opts.strict) { - continue + task := "-" + var wtAbs string + if mode == modeCodex { + var err error + wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + if !isUnderDir(codexWorktrees, wtAbs) { + continue + } + task = filepath.Base(wtAbs) + if branch == "" { + branch = "detached" + } + if query != "" && task != query { + continue + } + } else { + task, _ = worktree.TaskFromPath(repo, wt.Path) + if task == "" { + task = "-" + } + if query != "" && !matchesTask(task, query, opts.strict) { + continue + } } if opts.branch != "" && branch != opts.branch { continue @@ -123,21 +175,37 @@ func newStatusCommand() *cobra.Command { if err != nil { return err } + if wtAbs == "" { + wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + } + modified := "" + info, err := os.Stat(wtAbs) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("stat worktree %s: %w", wtAbs, err) + } + } else { + modified = info.ModTime().UTC().Format(time.RFC3339) + } rows = append(rows, statusRow{ - Task: task, - Branch: branch, - Path: displayPath(repoRoot, wt.Path, opts.abs), - Base: statusInfo.Base, - Target: target, - LastCommit: statusInfo.LastCommit, - Dirty: statusInfo.Dirty, - Ahead: statusInfo.Ahead, - Behind: statusInfo.Behind, + Task: task, + Branch: branch, + Path: displayPathForMode(repoRoot, wt.Path, opts.abs, mode, codexHome), + ModifiedTime: modified, + Base: statusInfo.Base, + Target: target, + LastCommit: statusInfo.LastCommit, + Dirty: statusInfo.Dirty, + Ahead: statusInfo.Ahead, + Behind: statusInfo.Behind, }) } - if len(rows) == 0 { + if mode != modeCodex && len(rows) == 0 { fallbackBranch := opts.branch if fallbackBranch == "" { fallbackBranch = query @@ -155,16 +223,26 @@ func newStatusCommand() *cobra.Command { if err != nil { return err } + modified := "" + info, err := os.Stat(path) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("stat worktree %s: %w", path, err) + } + } else { + modified = info.ModTime().UTC().Format(time.RFC3339) + } rows = append(rows, statusRow{ - Task: "-", - Branch: branch, - Path: displayPath(repoRoot, path, opts.abs), - Base: statusInfo.Base, - Target: target, - LastCommit: statusInfo.LastCommit, - Dirty: statusInfo.Dirty, - Ahead: statusInfo.Ahead, - Behind: statusInfo.Behind, + Task: "-", + Branch: branch, + Path: displayPath(repoRoot, path, opts.abs), + ModifiedTime: modified, + Base: statusInfo.Base, + Target: target, + LastCommit: statusInfo.LastCommit, + Dirty: statusInfo.Dirty, + Ahead: statusInfo.Ahead, + Behind: statusInfo.Behind, }) } } @@ -192,6 +270,7 @@ func renderStatus(cmd *cobra.Command, format string, rows []statusRow, grid bool {Header: "TASK", MinWidth: 6}, {Header: "BRANCH", MinWidth: 10, Flexible: true, Truncate: true, Style: func(value string) lipgloss.Style { return ui.AccentStyle }}, {Header: "PATH", MinWidth: 16, Flexible: true, Truncate: true}, + {Header: "MODIFIED", MinWidth: 10, Flexible: true, Truncate: true, Style: func(value string) lipgloss.Style { return ui.MutedStyle }}, {Header: "BASE", MinWidth: 8, Flexible: true, Truncate: true, Style: func(value string) lipgloss.Style { return ui.MutedStyle }}, {Header: "TARGET", MinWidth: 8, Flexible: true, Truncate: true, Style: func(value string) lipgloss.Style { return ui.MutedStyle }}, {Header: "LAST_COMMIT", MinWidth: 12, MaxWidth: 24, Flexible: true, Truncate: true, Style: func(value string) lipgloss.Style { return ui.MutedStyle }}, @@ -220,6 +299,7 @@ func renderStatus(cmd *cobra.Command, format string, rows []statusRow, grid bool row.Task, row.Branch, row.Path, + row.ModifiedTime, row.Base, row.Target, row.LastCommit, @@ -242,7 +322,7 @@ func renderStatus(cmd *cobra.Command, format string, rows []statusRow, grid bool case "csv": writer := csv.NewWriter(cmd.OutOrStdout()) if err := writer.Write([]string{ - "task", "branch", "path", "base", "target", "last_commit", "dirty", "ahead", "behind", + "task", "branch", "path", "modified_time", "base", "target", "last_commit", "dirty", "ahead", "behind", }); err != nil { return err } @@ -251,6 +331,7 @@ func renderStatus(cmd *cobra.Command, format string, rows []statusRow, grid bool row.Task, row.Branch, row.Path, + row.ModifiedTime, row.Base, row.Target, row.LastCommit, diff --git a/cli/sync.go b/cli/sync.go new file mode 100644 index 0000000..cea81ba --- /dev/null +++ b/cli/sync.go @@ -0,0 +1,411 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/pi2pie/git-worktree-tasks/internal/git" + "github.com/pi2pie/git-worktree-tasks/internal/worktree" + "github.com/pi2pie/git-worktree-tasks/ui" + "github.com/spf13/cobra" +) + +type syncOptions struct { + yes bool + dryRun bool +} + +type syncConflictError struct { + reason string + err error +} + +func (e *syncConflictError) Error() string { + if e.err == nil { + return e.reason + } + return fmt.Sprintf("%s: %v", e.reason, e.err) +} + +func (e *syncConflictError) Unwrap() error { return e.err } + +func newSyncCommand() *cobra.Command { + opts := &syncOptions{} + cmd := &cobra.Command{ + Use: "sync ", + Short: "Sync changes between a Codex worktree and the local checkout", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + cfg, ok := configFromContext(ctx) + if !ok || cfg.Mode != modeCodex { + return fmt.Errorf("sync is only supported in --mode=codex") + } + + runner := defaultRunner() + repoRoot, err := repoRoot(ctx, runner) + if err != nil { + return err + } + if _, err := git.CurrentBranch(ctx, runner); err != nil { + return err + } + + if !cmd.Flags().Changed("yes") { + opts.yes = !cfg.Cleanup.Confirm + } + + opaqueID := strings.TrimSpace(args[0]) + if opaqueID == "" { + return fmt.Errorf("task query cannot be empty") + } + + codexHome, err := codexHomeDir() + if err != nil { + return err + } + codexWorktrees := codexWorktreesRoot(codexHome) + + wtPath, ok, err := resolveCodexWorktreePath(ctx, runner, repoRoot, codexWorktrees, opaqueID) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("no Codex worktree found for %q under %s", opaqueID, filepath.Join("$CODEX_HOME", "worktrees")) + } + + conflictReasons, err := detectSyncConflicts(ctx, runner, repoRoot, wtPath) + if err != nil { + return err + } + if len(conflictReasons) > 0 { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.WarningStyle.Render("sync conflict detected:")); err != nil { + return err + } + for _, reason := range conflictReasons { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "- %s\n", ui.WarningStyle.Render(reason)); err != nil { + return err + } + } + + if !opts.yes { + ok, err := confirmPrompt(cmd.InOrStdin(), cmd.OutOrStdout(), "Overwrite the Codex worktree from the local checkout?") + if err != nil { + return err + } + if !ok { + return errCanceled + } + ok, err = confirmPrompt(cmd.InOrStdin(), cmd.OutOrStdout(), "This will discard worktree changes. Continue?") + if err != nil { + return err + } + if !ok { + return errCanceled + } + } + + return syncOverwrite(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) + } + + if err := syncApply(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun); err != nil { + var conflictErr *syncConflictError + if errors.As(err, &conflictErr) { + if !opts.yes { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.WarningStyle.Render(conflictErr.reason)); err != nil { + return err + } + ok, err := confirmPrompt(cmd.InOrStdin(), cmd.OutOrStdout(), "Overwrite the Codex worktree from the local checkout?") + if err != nil { + return err + } + if !ok { + return errCanceled + } + ok, err = confirmPrompt(cmd.InOrStdin(), cmd.OutOrStdout(), "This will discard worktree changes. Continue?") + if err != nil { + return err + } + if !ok { + return errCanceled + } + } + return syncOverwrite(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) + } + return err + } + if _, err := fmt.Fprintln(cmd.OutOrStdout(), ui.SuccessStyle.Render("sync complete")); err != nil { + return err + } + return nil + }, + } + + cmd.Flags().BoolVar(&opts.yes, "yes", false, "skip confirmation prompts") + cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "show git commands without executing") + return cmd +} + +func resolveCodexWorktreePath(ctx context.Context, runner git.Runner, repoRoot, codexWorktreesRoot, opaqueID string) (string, bool, error) { + worktrees, err := worktree.List(ctx, runner, repoRoot) + if err != nil { + return "", false, err + } + for _, wt := range worktrees { + wtAbs, err := worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return "", false, err + } + if !isUnderDir(codexWorktreesRoot, wtAbs) { + continue + } + if filepath.Base(wtAbs) != opaqueID { + continue + } + return wtAbs, true, nil + } + return "", false, nil +} + +func detectSyncConflicts(ctx context.Context, runner git.Runner, repoRoot, worktreePath string) ([]string, error) { + var reasons []string + + dirty, err := isDirty(ctx, runner, repoRoot) + if err != nil { + return nil, err + } + if dirty { + reasons = append(reasons, "local checkout has uncommitted changes") + } + + localModified, err := modifiedFiles(ctx, runner, repoRoot) + if err != nil { + return nil, err + } + worktreeModified, err := modifiedFiles(ctx, runner, worktreePath) + if err != nil { + return nil, err + } + if intersects(localModified, worktreeModified) { + reasons = append(reasons, "both sides modified the same file(s)") + } + + return reasons, nil +} + +func isDirty(ctx context.Context, runner git.Runner, repoRoot string) (bool, error) { + stdout, stderr, err := runner.Run(ctx, "-C", repoRoot, "status", "--porcelain") + if err != nil { + if stderr != "" { + return false, fmt.Errorf("git status: %w: %s", err, stderr) + } + return false, fmt.Errorf("git status: %w", err) + } + return strings.TrimSpace(stdout) != "", nil +} + +func modifiedFiles(ctx context.Context, runner git.Runner, repoRoot string) (map[string]struct{}, error) { + files := map[string]struct{}{} + + diffNames, stderr, err := runner.Run(ctx, "-C", repoRoot, "diff", "--name-only", "HEAD") + if err != nil { + if stderr != "" { + return nil, fmt.Errorf("git diff --name-only: %w: %s", err, stderr) + } + return nil, fmt.Errorf("git diff --name-only: %w", err) + } + for _, line := range strings.Split(diffNames, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + files[trimmed] = struct{}{} + } + + untracked, stderr, err := runner.Run(ctx, "-C", repoRoot, "ls-files", "--others", "--exclude-standard") + if err != nil { + if stderr != "" { + return nil, fmt.Errorf("git ls-files: %w: %s", err, stderr) + } + return nil, fmt.Errorf("git ls-files: %w", err) + } + for _, line := range strings.Split(untracked, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + files[trimmed] = struct{}{} + } + + return files, nil +} + +func intersects(left, right map[string]struct{}) bool { + if len(left) == 0 || len(right) == 0 { + return false + } + if len(left) > len(right) { + left, right = right, left + } + for key := range left { + if _, ok := right[key]; ok { + return true + } + } + return false +} + +func syncApply(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoRoot, worktreePath string, dryRun bool) error { + patch, err := gitDiff(ctx, runner, worktreePath) + if err != nil { + return err + } + + patchFile, err := writeTempPatch(patch) + if err != nil { + return err + } + defer os.Remove(patchFile) + + if patch != "" { + if err := runGit(ctx, cmd, dryRun, runner, "-C", repoRoot, "apply", "--check", patchFile); err != nil { + return &syncConflictError{reason: "apply patch check failed", err: err} + } + if err := runGit(ctx, cmd, dryRun, runner, "-C", repoRoot, "apply", patchFile); err != nil { + return fmt.Errorf("apply patch: %w", err) + } + } + + untracked, err := listUntracked(ctx, runner, worktreePath) + if err != nil { + return err + } + for _, rel := range untracked { + if err := copyFile(worktreePath, repoRoot, rel, dryRun, cmd.OutOrStdout()); err != nil { + return err + } + } + + return nil +} + +func syncOverwrite(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoRoot, worktreePath string, dryRun bool) error { + if err := runGit(ctx, cmd, dryRun, runner, "-C", worktreePath, "reset", "--hard"); err != nil { + return err + } + if err := runGit(ctx, cmd, dryRun, runner, "-C", worktreePath, "clean", "-fd"); err != nil { + return err + } + + patch, err := gitDiff(ctx, runner, repoRoot) + if err != nil { + return err + } + patchFile, err := writeTempPatch(patch) + if err != nil { + return err + } + defer os.Remove(patchFile) + + if patch != "" { + if err := runGit(ctx, cmd, dryRun, runner, "-C", worktreePath, "apply", patchFile); err != nil { + return err + } + } + + untracked, err := listUntracked(ctx, runner, repoRoot) + if err != nil { + return err + } + for _, rel := range untracked { + if err := copyFile(repoRoot, worktreePath, rel, dryRun, cmd.OutOrStdout()); err != nil { + return err + } + } + + if _, err := fmt.Fprintln(cmd.OutOrStdout(), ui.SuccessStyle.Render("overwrite complete")); err != nil { + return err + } + return nil +} + +func gitDiff(ctx context.Context, runner git.Runner, repoRoot string) (string, error) { + stdout, stderr, err := runner.Run(ctx, "-C", repoRoot, "diff", "--binary", "HEAD") + if err != nil { + if stderr != "" { + return "", fmt.Errorf("git diff: %w: %s", err, stderr) + } + return "", fmt.Errorf("git diff: %w", err) + } + return stdout, nil +} + +func listUntracked(ctx context.Context, runner git.Runner, repoRoot string) ([]string, error) { + stdout, stderr, err := runner.Run(ctx, "-C", repoRoot, "ls-files", "--others", "--exclude-standard") + if err != nil { + if stderr != "" { + return nil, fmt.Errorf("git ls-files: %w: %s", err, stderr) + } + return nil, fmt.Errorf("git ls-files: %w", err) + } + var out []string + for _, line := range strings.Split(stdout, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + out = append(out, trimmed) + } + return out, nil +} + +func writeTempPatch(contents string) (string, error) { + tmp, err := os.CreateTemp("", "gwtt-sync-*.patch") + if err != nil { + return "", err + } + defer tmp.Close() + if _, err := io.WriteString(tmp, contents); err != nil { + return "", err + } + return tmp.Name(), nil +} + +func copyFile(srcRoot, dstRoot, rel string, dryRun bool, out io.Writer) error { + if dryRun { + _, err := fmt.Fprintf(out, "copy %s -> %s\n", filepath.Join(srcRoot, rel), filepath.Join(dstRoot, rel)) + return err + } + srcPath := filepath.Join(srcRoot, rel) + dstPath := filepath.Join(dstRoot, rel) + + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + in, err := os.Open(srcPath) + if err != nil { + return err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return err + } + + outFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) + if err != nil { + return err + } + defer outFile.Close() + + if _, err := io.Copy(outFile, in); err != nil { + return err + } + return nil +} diff --git a/docs/plans/jobs/2026-02-04-implement-mode-flag-phase2.md b/docs/plans/jobs/2026-02-04-implement-mode-flag-phase2.md new file mode 100644 index 0000000..905b34a --- /dev/null +++ b/docs/plans/jobs/2026-02-04-implement-mode-flag-phase2.md @@ -0,0 +1,18 @@ +--- +title: "Implement --mode and codex-mode commands (Phase 2)" +date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +Implemented Phase 2 of `classic` vs `codex` mode support: +- Added global `--mode` flag and mode normalization/validation. +- Added config/env support for `mode` (`GWTT_MODE`, `mode = "..."` in TOML). +- Implemented codex-mode behavior for `list`, `status` (repo-scoped, `$CODEX_HOME` path display), and added `modified_time` to status output. +- Added `sync` command for codex mode (`apply` by default; prompts to overwrite on conflicts). +- Updated `cleanup` to support codex-mode worktree removal under `$CODEX_HOME/worktrees/` with additional warnings/confirmation. + +## Notes +- Tests were executed with `GOCACHE` pointed at a writable location due to sandbox constraints. + diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md index fd9d1a4..917c74d 100644 --- a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -32,30 +32,30 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - [x] `cleanup` safety model (scope restriction, warnings, second confirmation, and “restore is best-effort” note). ### Phase 2: Code Implementation -- [ ] Add global `--mode` persistent flag on `cli/root.go` and plumb mode into command execution (context/config). -- [ ] Add mode to config resolution: - - [ ] Env: `GWTT_MODE`. - - [ ] Config: `mode = "classic"|"codex"`. - - [ ] Validation and error messaging for unsupported values. -- [ ] Codex-mode worktree discovery primitives: - - [ ] Determine `$CODEX_HOME` (or `CODEX_HOME`) and `worktreesRoot := $CODEX_HOME/worktrees`. - - [ ] Use `internal/worktree.List(ctx, runner, repoRoot)` and filter entries whose path is under `worktreesRoot`. - - [ ] Derive `` as `filepath.Base(worktreePath)`. -- [ ] Implement codex-mode behavior for read-only commands: - - [ ] `gwtt list` shows codex worktrees for the current repo with `Task=` and a `$CODEX_HOME/...`-aware display path. - - [ ] `gwtt status` does the same, plus `modified_time`. -- [ ] Add `modified_time` to status rows: - - [ ] Use filesystem `mtime` of the worktree directory. - - [ ] Format as RFC3339 UTC for JSON/CSV; table uses the same value. -- [ ] Add `gwtt sync ` (codex-mode only): - - [ ] Default to “apply” (worktree -> local checkout). - - [ ] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). - - [ ] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. - - [ ] Keep behavior aligned with Codex App: ignored files are not synced. -- [ ] Re-check `cleanup` behavior for codex mode: - - [ ] Restrict deletions to `$CODEX_HOME/worktrees/` only. - - [ ] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. - - [ ] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). +- [x] Add global `--mode` persistent flag on `cli/root.go` and plumb mode into command execution (context/config). +- [x] Add mode to config resolution: + - [x] Env: `GWTT_MODE`. + - [x] Config: `mode = "classic"|"codex"`. + - [x] Validation and error messaging for unsupported values. +- [x] Codex-mode worktree discovery primitives: + - [x] Determine `$CODEX_HOME` (or `CODEX_HOME`) and `worktreesRoot := $CODEX_HOME/worktrees`. + - [x] Use `internal/worktree.List(ctx, runner, repoRoot)` and filter entries whose path is under `worktreesRoot`. + - [x] Derive `` as `filepath.Base(worktreePath)`. +- [x] Implement codex-mode behavior for read-only commands: + - [x] `gwtt list` shows codex worktrees for the current repo with `Task=` and a `$CODEX_HOME/...`-aware display path. + - [x] `gwtt status` does the same, plus `modified_time`. +- [x] Add `modified_time` to status rows: + - [x] Use filesystem `mtime` of the worktree directory. + - [x] Format as RFC3339 UTC for JSON/CSV; table uses the same value. +- [x] Add `gwtt sync ` (codex-mode only): + - [x] Default to “apply” (worktree -> local checkout). + - [x] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). + - [x] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. + - [x] Keep behavior aligned with Codex App: ignored files are not synced. +- [x] Re-check `cleanup` behavior for codex mode: + - [x] Restrict deletions to `$CODEX_HOME/worktrees/` only. + - [x] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. + - [x] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). ### Phase 3: Unit Test Verification - [ ] Add tests for `mode` precedence and validation (flag/env/config/default). diff --git a/internal/config/config.go b/internal/config/config.go index a9de1d2..fe23722 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,9 +11,11 @@ import ( const ( envColorEnabled = "GWTT_COLOR" + envMode = "GWTT_MODE" ) type Config struct { + Mode string Theme ThemeConfig UI UIConfig Table TableConfig @@ -81,6 +83,7 @@ type CleanupConfig struct { func DefaultConfig() Config { return Config{ + Mode: "classic", Theme: ThemeConfig{ Name: "default", }, @@ -130,6 +133,7 @@ func DefaultConfig() Config { } type loadedConfigFile struct { + Mode *string `toml:"mode"` Theme themeConfigFile `toml:"theme"` UI uiConfigFile `toml:"ui"` Table tableConfigFile `toml:"table"` @@ -283,6 +287,9 @@ func applyEnvConfig(cfg *Config) error { if name, ok := envString(envThemeName); ok { cfg.Theme.Name = name } + if mode, ok := envString(envMode); ok { + cfg.Mode = mode + } if enabled, ok, err := envBool(envColorEnabled); err != nil { return err } else if ok { @@ -323,6 +330,9 @@ func envBool(key string) (bool, bool, error) { } func applyConfig(cfg *Config, flags *gridFlags, file loadedConfigFile) { + if mode, ok := trimString(file.Mode); ok { + cfg.Mode = mode + } if name, ok := trimString(file.Theme.Name); ok { cfg.Theme.Name = name } From 41948f94174203da74bce56b721a23abd872c402 Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 15:53:00 +0800 Subject: [PATCH 08/19] chore: rollback the research and plan --- .../plan-2026-02-04-mode-classic-and-codex.md | 40 ++++++++++--------- ...search-2026-02-04-mode-classic-vs-codex.md | 8 +++- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md index 917c74d..4d959e1 100644 --- a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -37,25 +37,27 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - [x] Env: `GWTT_MODE`. - [x] Config: `mode = "classic"|"codex"`. - [x] Validation and error messaging for unsupported values. -- [x] Codex-mode worktree discovery primitives: - - [x] Determine `$CODEX_HOME` (or `CODEX_HOME`) and `worktreesRoot := $CODEX_HOME/worktrees`. - - [x] Use `internal/worktree.List(ctx, runner, repoRoot)` and filter entries whose path is under `worktreesRoot`. - - [x] Derive `` as `filepath.Base(worktreePath)`. -- [x] Implement codex-mode behavior for read-only commands: - - [x] `gwtt list` shows codex worktrees for the current repo with `Task=` and a `$CODEX_HOME/...`-aware display path. - - [x] `gwtt status` does the same, plus `modified_time`. -- [x] Add `modified_time` to status rows: - - [x] Use filesystem `mtime` of the worktree directory. - - [x] Format as RFC3339 UTC for JSON/CSV; table uses the same value. -- [x] Add `gwtt sync ` (codex-mode only): - - [x] Default to “apply” (worktree -> local checkout). - - [x] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). - - [x] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. - - [x] Keep behavior aligned with Codex App: ignored files are not synced. -- [x] Re-check `cleanup` behavior for codex mode: - - [x] Restrict deletions to `$CODEX_HOME/worktrees/` only. - - [x] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. - - [x] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). +- [ ] Codex-mode worktree discovery primitives: + - [ ] Determine `$CODEX_HOME` (or `CODEX_HOME`) and `worktreesRoot := $CODEX_HOME/worktrees`. + - [ ] Use `internal/worktree.List(ctx, runner, repoRoot)` and filter entries whose path is under `worktreesRoot`. + - [ ] Derive `` as **the first path segment** under `$CODEX_HOME/worktrees` (e.g., `$CODEX_HOME/worktrees/bf15/` → `bf15`). +- [ ] Implement codex-mode behavior for read-only commands: + - [ ] `gwtt list` shows codex worktrees for the current repo with `Task=` and a `$CODEX_HOME/...`-aware display path. + - [ ] `gwtt status` does the same, plus `modified_time`. + - [ ] In classic mode, hide codex worktrees by default (treat detached HEAD under `$CODEX_HOME/worktrees` as codex-owned). +- [ ] Add `modified_time` to status rows: + - [ ] Use filesystem `mtime` of the worktree directory. + - [ ] Format as RFC3339 UTC for JSON/CSV; table uses the same value. +- [ ] Add `gwtt sync ` (codex-mode only): + - [ ] Default to “apply” (worktree -> local checkout). + - [ ] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). + - [ ] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. + - [ ] Keep behavior aligned with Codex App: ignored files are not synced. +- [ ] Re-check `cleanup` behavior for codex mode: + - [ ] Restrict deletions to `$CODEX_HOME/worktrees/` only. + - [ ] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. + - [ ] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). + - [ ] Ensure codex-mode `--output raw` returns a composable path (relative or absolute) rather than `$CODEX_HOME` placeholders; keep `$CODEX_HOME/...` for display formats. ### Phase 3: Unit Test Verification - [ ] Add tests for `mode` precedence and validation (flag/env/config/default). diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md index 6074020..54e737f 100644 --- a/docs/research-2026-02-04-mode-classic-vs-codex.md +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -40,7 +40,9 @@ To keep `classic` stable and keep `codex` aligned with Codex App: - **Identity & mapping via inspection (no extra registry files):** - Do not introduce new state files like `registry.json`/TOML for codex mode. - For `list`/`status`, inspect `$CODEX_HOME/worktrees/**` (and/or `git worktree list --porcelain` scoped to the local checkout) to discover worktrees and compute status. - - In codex mode, `` is the **exact** worktree directory name under `$CODEX_HOME/worktrees` (an opaque ID). + - In codex mode, `` is the **opaque ID directory** directly under `$CODEX_HOME/worktrees`. + - Example path: `~/.codex/worktrees/bf15/git-worktree-tasks` + - `` is `bf15` (the opaque ID), **not** `git-worktree-tasks`. - **Create is detached-only:** in `codex` mode, `create` should not offer a `--branch` escape hatch; the default stays detached to avoid future complexity. - **Finish is classic-only:** in `codex` mode, `finish` is not a good fit; use a dedicated `sync` command instead. - **Cleanup follows current mode:** `gwtt cleanup` should operate on the worktrees owned by the active mode (`classic` naming vs `$CODEX_HOME/worktrees`), rather than mixing behaviors. @@ -56,6 +58,7 @@ To keep `classic` stable and keep `codex` aligned with Codex App: - **Different command surface:** branch-merge workflows (`finish`) are replaced by sync workflows (`sync apply` / `sync overwrite`). - **Cleanup restrictions from Codex App:** Codex App’s auto-cleanup is disabled in some cases (e.g., pinned conversation, added to sidebar, age > 4 days, worktree count > 10). - Note: the “age > 4 days” / “count > 10” conditions are counterintuitive, but this is the wording in the official docs as of 2026-02-04. + - **Detached HEAD as codex marker:** codex-mode worktrees are detached; classic-mode worktrees are expected to be on a branch. Use this to keep classic commands from “seeing” codex worktrees. ### Path display differences (UX) Codex App uses a “variable-aware” presentation of paths (and the user specifically called out `$CODEX_HOME`): @@ -105,7 +108,7 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif - Display paths under this root as `$CODEX_HOME/...` by default (unless `--abs` forces absolute). ### Selection model (`` in codex mode) -- `` is the exact `` directory name under `$CODEX_HOME/worktrees`. +- `` is the **exact opaque ID** directory name under `$CODEX_HOME/worktrees` (the first path segment). - `gwtt list/status --mode=codex` should render `TASK=` to make the identifier discoverable and copy/paste friendly. ### Repo scoping (`list/status` in codex mode) @@ -137,6 +140,7 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif ## Open Questions (Remaining) - Can we (safely) detect any Codex cleanup-restriction signals from disk without coupling `gwtt` to Codex’s internal storage formats? - What is the most user-friendly confirmation wording for “overwrite” (sync) and “yolo delete” (cleanup) that still prevents accidents? +- Should `--output raw` in codex mode return **relative paths** (for composability) while display output uses `$CODEX_HOME/...`? ## Notes - Restoration remains out of scope: keep to create/sync/list/status only for now; a future `restore` likely needs to integrate with Codex App snapshot state. From 1dca0dbcfc5ed0f72085d370c7d7eec2dc0f9856 Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 15:55:21 +0800 Subject: [PATCH 09/19] chore: bump version --- cli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/root.go b/cli/root.go index 22b54ad..ce610b1 100644 --- a/cli/root.go +++ b/cli/root.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var Version = "0.1.1-alpha.0" +var Version = "0.1.1-alpha.1" var ( errCanceled = errors.New("git worktree task process canceled") From d75ae8eb8296c5ddacc0afee11589d9c5238547d Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 16:30:31 +0800 Subject: [PATCH 10/19] fix: clarify Codex cleanup and raw output --- docs/research-2026-02-04-mode-classic-vs-codex.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md index 54e737f..3ec4999 100644 --- a/docs/research-2026-02-04-mode-classic-vs-codex.md +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -130,17 +130,22 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif - Scope: only delete the on-disk directory at `$CODEX_HOME/worktrees/` (free disk; do not touch classic paths). - Since pinned/sidebar/thread linkage is not reliably detectable without reading Codex App state: - Always show a prominent warning (“may break pinned/sidebar restore expectations”) and require an extra confirmation (skippable with `--yes`). - - Treat restore as best-effort: `gwtt` cannot guarantee a snapshot exists before manual deletion. + - Treat restore as best-effort: Codex saves a snapshot **before its own cleanup**; `gwtt`-initiated deletion cannot guarantee a snapshot exists unless Codex already took one. ### `status` metadata addition: `modified_time` - Add `modified_time` derived from filesystem `mtime` of the worktree directory. - Format: RFC3339 UTC for JSON/CSV; table output prints the same string. - No date-format config in the first iteration. +### Raw output (codex mode) +- `--output raw` should return a **composable path** (relative to `$CODEX_HOME`), e.g. `worktrees/bf15/git-worktree-tasks`. +- Display output (table/text) should continue to render `$CODEX_HOME/...` for readability. + ## Open Questions (Remaining) - Can we (safely) detect any Codex cleanup-restriction signals from disk without coupling `gwtt` to Codex’s internal storage formats? + - Likely no; default to warnings + a second confirmation (skippable with `--yes`) for codex cleanup. - What is the most user-friendly confirmation wording for “overwrite” (sync) and “yolo delete” (cleanup) that still prevents accidents? -- Should `--output raw` in codex mode return **relative paths** (for composability) while display output uses `$CODEX_HOME/...`? + - Pending: depends on user confidence that Codex can restore a worktree after manual deletion (docs only guarantee snapshots before **Codex-managed** cleanup). ## Notes - Restoration remains out of scope: keep to create/sync/list/status only for now; a future `restore` likely needs to integrate with Codex App snapshot state. From ff0d924c500a572be39f908a3662771e8457b76e Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 16:38:50 +0800 Subject: [PATCH 11/19] fix: derive Codex opaque-id and adjust worktrees --- cli/cleanup.go | 9 ++-- cli/codex.go | 22 ++++++++++ cli/list.go | 31 ++++++++++++-- cli/status.go | 22 ++++++++-- cli/sync.go | 6 +-- ...-02-04-fix-codex-opaque-id-and-raw-path.md | 17 ++++++++ .../plan-2026-02-04-mode-classic-and-codex.md | 42 +++++++++---------- 7 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 docs/plans/jobs/2026-02-04-fix-codex-opaque-id-and-raw-path.md diff --git a/cli/cleanup.go b/cli/cleanup.go index 08fa22c..aff6b1b 100644 --- a/cli/cleanup.go +++ b/cli/cleanup.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "path/filepath" "strings" "github.com/pi2pie/git-worktree-tasks/internal/git" @@ -115,13 +114,11 @@ func newCleanupCommand() *cobra.Command { if err != nil { return err } - if !isUnderDir(codexWorktrees, wtAbs) { + opaqueID, _, ok := codexWorktreeInfo(codexWorktrees, wtAbs) + if !ok || opaqueID != query { continue } - if filepath.Base(wtAbs) != query { - continue - } - resolvedPath = wt.Path + resolvedPath = wtAbs worktreeExists = true break } diff --git a/cli/codex.go b/cli/codex.go index bd754d1..dbb5ac3 100644 --- a/cli/codex.go +++ b/cli/codex.go @@ -40,3 +40,25 @@ func codexHomeDir() (string, error) { func codexWorktreesRoot(codexHome string) string { return filepath.Join(codexHome, "worktrees") } + +func codexWorktreeInfo(codexWorktreesRoot, worktreePath string) (opaqueID, relative string, ok bool) { + if strings.TrimSpace(codexWorktreesRoot) == "" || strings.TrimSpace(worktreePath) == "" { + return "", "", false + } + if !isUnderDir(codexWorktreesRoot, worktreePath) { + return "", "", false + } + rel, err := filepath.Rel(codexWorktreesRoot, worktreePath) + if err != nil { + return "", "", false + } + rel = filepath.Clean(rel) + if rel == "." || rel == string(filepath.Separator) { + return "", "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" || parts[0] == "." { + return "", "", false + } + return parts[0], rel, true +} diff --git a/cli/list.go b/cli/list.go index 50124bc..f1a62d0 100644 --- a/cli/list.go +++ b/cli/list.go @@ -54,6 +54,11 @@ func newListCommand() *cobra.Command { return err } codexWorktrees = codexWorktreesRoot(codexHome) + } else { + if home, err := codexHomeDir(); err == nil { + codexHome = home + codexWorktrees = codexWorktreesRoot(codexHome) + } } if !cmd.Flags().Changed("output") { opts.output = cfg.List.Output @@ -118,19 +123,33 @@ func newListCommand() *cobra.Command { for _, wt := range worktrees { branch := strings.TrimPrefix(wt.Branch, "refs/heads/") task := "-" + var codexRel string + var wtAbs string + var err error if mode == modeCodex { - wtAbs, err := worktree.NormalizePath(repoRoot, wt.Path) + wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) if err != nil { return err } - if !isUnderDir(codexWorktrees, wtAbs) { + opaqueID, rel, ok := codexWorktreeInfo(codexWorktrees, wtAbs) + if !ok { continue } - task = filepath.Base(wtAbs) + task = opaqueID + codexRel = rel if branch == "" { branch = "detached" } } else { + if codexWorktrees != "" { + wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + if _, _, ok := codexWorktreeInfo(codexWorktrees, wtAbs); ok { + continue + } + } task, _ = worktree.TaskFromPath(repo, wt.Path) if task == "" { task = "-" @@ -143,6 +162,12 @@ func newListCommand() *cobra.Command { Present: true, Head: worktree.ShortHash(wt.Head, shortHashLen), } + if mode == modeCodex && opts.output == "raw" && field == "path" { + if codexRel == "" { + return fmt.Errorf("unable to derive codex worktree relative path for %q", wt.Path) + } + row.Path = filepath.Join("worktrees", codexRel) + } if query != "" { if mode == modeCodex { if row.Task != query { diff --git a/cli/status.go b/cli/status.go index 09f80d0..3abae1a 100644 --- a/cli/status.go +++ b/cli/status.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "strconv" "strings" "time" @@ -62,6 +61,11 @@ func newStatusCommand() *cobra.Command { return err } codexWorktrees = codexWorktreesRoot(codexHome) + } else { + if home, err := codexHomeDir(); err == nil { + codexHome = home + codexWorktrees = codexWorktreesRoot(codexHome) + } } if !cmd.Flags().Changed("output") { opts.output = cfg.Status.Output @@ -148,10 +152,11 @@ func newStatusCommand() *cobra.Command { if err != nil { return err } - if !isUnderDir(codexWorktrees, wtAbs) { + opaqueID, _, ok := codexWorktreeInfo(codexWorktrees, wtAbs) + if !ok { continue } - task = filepath.Base(wtAbs) + task = opaqueID if branch == "" { branch = "detached" } @@ -159,6 +164,16 @@ func newStatusCommand() *cobra.Command { continue } } else { + if codexWorktrees != "" { + var err error + wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + if _, _, ok := codexWorktreeInfo(codexWorktrees, wtAbs); ok { + continue + } + } task, _ = worktree.TaskFromPath(repo, wt.Path) if task == "" { task = "-" @@ -176,6 +191,7 @@ func newStatusCommand() *cobra.Command { return err } if wtAbs == "" { + var err error wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) if err != nil { return err diff --git a/cli/sync.go b/cli/sync.go index cea81ba..c470d96 100644 --- a/cli/sync.go +++ b/cli/sync.go @@ -161,10 +161,8 @@ func resolveCodexWorktreePath(ctx context.Context, runner git.Runner, repoRoot, if err != nil { return "", false, err } - if !isUnderDir(codexWorktreesRoot, wtAbs) { - continue - } - if filepath.Base(wtAbs) != opaqueID { + id, _, ok := codexWorktreeInfo(codexWorktreesRoot, wtAbs) + if !ok || id != opaqueID { continue } return wtAbs, true, nil diff --git a/docs/plans/jobs/2026-02-04-fix-codex-opaque-id-and-raw-path.md b/docs/plans/jobs/2026-02-04-fix-codex-opaque-id-and-raw-path.md new file mode 100644 index 0000000..7c8d9b7 --- /dev/null +++ b/docs/plans/jobs/2026-02-04-fix-codex-opaque-id-and-raw-path.md @@ -0,0 +1,17 @@ +--- +title: "Fix codex opaque-id mapping and raw output paths" +date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +Adjusted codex-mode worktree handling to match Codex App directory layout: +- Derive `` from the first path segment under `$CODEX_HOME/worktrees`. +- Hide codex-owned worktrees in classic mode. +- Make `--output raw` in codex mode return a composable path relative to `$CODEX_HOME`. +- Updated sync/cleanup resolution to use the corrected opaque-id mapping. + +## Notes +- Tests run with `GOCACHE=/tmp/gocache` due to sandbox cache restrictions. + diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md index 4d959e1..2844f8c 100644 --- a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -37,27 +37,27 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - [x] Env: `GWTT_MODE`. - [x] Config: `mode = "classic"|"codex"`. - [x] Validation and error messaging for unsupported values. -- [ ] Codex-mode worktree discovery primitives: - - [ ] Determine `$CODEX_HOME` (or `CODEX_HOME`) and `worktreesRoot := $CODEX_HOME/worktrees`. - - [ ] Use `internal/worktree.List(ctx, runner, repoRoot)` and filter entries whose path is under `worktreesRoot`. - - [ ] Derive `` as **the first path segment** under `$CODEX_HOME/worktrees` (e.g., `$CODEX_HOME/worktrees/bf15/` → `bf15`). -- [ ] Implement codex-mode behavior for read-only commands: - - [ ] `gwtt list` shows codex worktrees for the current repo with `Task=` and a `$CODEX_HOME/...`-aware display path. - - [ ] `gwtt status` does the same, plus `modified_time`. - - [ ] In classic mode, hide codex worktrees by default (treat detached HEAD under `$CODEX_HOME/worktrees` as codex-owned). -- [ ] Add `modified_time` to status rows: - - [ ] Use filesystem `mtime` of the worktree directory. - - [ ] Format as RFC3339 UTC for JSON/CSV; table uses the same value. -- [ ] Add `gwtt sync ` (codex-mode only): - - [ ] Default to “apply” (worktree -> local checkout). - - [ ] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). - - [ ] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. - - [ ] Keep behavior aligned with Codex App: ignored files are not synced. -- [ ] Re-check `cleanup` behavior for codex mode: - - [ ] Restrict deletions to `$CODEX_HOME/worktrees/` only. - - [ ] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. - - [ ] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). - - [ ] Ensure codex-mode `--output raw` returns a composable path (relative or absolute) rather than `$CODEX_HOME` placeholders; keep `$CODEX_HOME/...` for display formats. +- [x] Codex-mode worktree discovery primitives: + - [x] Determine `$CODEX_HOME` (or `CODEX_HOME`) and `worktreesRoot := $CODEX_HOME/worktrees`. + - [x] Use `internal/worktree.List(ctx, runner, repoRoot)` and filter entries whose path is under `worktreesRoot`. + - [x] Derive `` as **the first path segment** under `$CODEX_HOME/worktrees` (e.g., `$CODEX_HOME/worktrees/bf15/` → `bf15`). +- [x] Implement codex-mode behavior for read-only commands: + - [x] `gwtt list` shows codex worktrees for the current repo with `Task=` and a `$CODEX_HOME/...`-aware display path. + - [x] `gwtt status` does the same, plus `modified_time`. + - [x] In classic mode, hide codex worktrees by default (treat detached HEAD under `$CODEX_HOME/worktrees` as codex-owned). +- [x] Add `modified_time` to status rows: + - [x] Use filesystem `mtime` of the worktree directory. + - [x] Format as RFC3339 UTC for JSON/CSV; table uses the same value. +- [x] Add `gwtt sync ` (codex-mode only): + - [x] Default to “apply” (worktree -> local checkout). + - [x] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). + - [x] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. + - [x] Keep behavior aligned with Codex App: ignored files are not synced. +- [x] Re-check `cleanup` behavior for codex mode: + - [x] Restrict deletions to `$CODEX_HOME/worktrees/` only. + - [x] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. + - [x] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). + - [x] Ensure codex-mode `--output raw` returns a composable path (relative or absolute) rather than `$CODEX_HOME` placeholders; keep `$CODEX_HOME/...` for display formats. ### Phase 3: Unit Test Verification - [ ] Add tests for `mode` precedence and validation (flag/env/config/default). From bffa03fb8ce29408725a91ac948c78ee7f56f7c8 Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 17:32:40 +0800 Subject: [PATCH 12/19] fix: codex raw path output handling --- cli/list.go | 37 ++++++++++++++----- .../jobs/2026-02-04-fix-codex-raw-paths.md | 12 ++++++ ...026-02-04-fix-codex-raw-relative-to-cwd.md | 10 +++++ 3 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 docs/plans/jobs/2026-02-04-fix-codex-raw-paths.md create mode 100644 docs/plans/jobs/2026-02-04-fix-codex-raw-relative-to-cwd.md diff --git a/cli/list.go b/cli/list.go index f1a62d0..eb0ece8 100644 --- a/cli/list.go +++ b/cli/list.go @@ -4,6 +4,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "os" "path/filepath" "strconv" "strings" @@ -45,6 +46,7 @@ func newListCommand() *cobra.Command { mode := modeClassic var codexHome string var codexWorktrees string + var rawBase string if cfg, ok := configFromContext(cmd.Context()); ok { mode = cfg.Mode if mode == modeCodex { @@ -76,6 +78,11 @@ func newListCommand() *cobra.Command { opts.strict = cfg.List.Strict } } + if mode == modeCodex && opts.output == "raw" && opts.field == "path" && !opts.abs { + if cwd, err := os.Getwd(); err == nil { + rawBase = cwd + } + } repoRoot, err := repoRoot(ctx, runner) if err != nil { return err @@ -94,7 +101,6 @@ func newListCommand() *cobra.Command { if query == "" { return fmt.Errorf("task query cannot be empty") } - opts.strict = true } else { query, err = normalizeTaskQuery(args[0]) if err != nil { @@ -163,17 +169,30 @@ func newListCommand() *cobra.Command { Head: worktree.ShortHash(wt.Head, shortHashLen), } if mode == modeCodex && opts.output == "raw" && field == "path" { - if codexRel == "" { - return fmt.Errorf("unable to derive codex worktree relative path for %q", wt.Path) + if opts.abs { + if wtAbs == "" { + return fmt.Errorf("unable to derive codex worktree absolute path for %q", wt.Path) + } + row.Path = wtAbs + } else { + if wtAbs == "" { + return fmt.Errorf("unable to derive codex worktree absolute path for %q", wt.Path) + } + if rawBase != "" { + if rel, err := filepath.Rel(rawBase, wtAbs); err == nil { + row.Path = rel + goto rawPathDone + } + } + if codexRel == "" { + return fmt.Errorf("unable to derive codex worktree relative path for %q", wt.Path) + } + row.Path = filepath.Join("worktrees", codexRel) } - row.Path = filepath.Join("worktrees", codexRel) } + rawPathDone: if query != "" { - if mode == modeCodex { - if row.Task != query { - continue - } - } else if !matchesTask(row.Task, query, opts.strict) { + if !matchesTask(row.Task, query, opts.strict) { continue } } diff --git a/docs/plans/jobs/2026-02-04-fix-codex-raw-paths.md b/docs/plans/jobs/2026-02-04-fix-codex-raw-paths.md new file mode 100644 index 0000000..76e7325 --- /dev/null +++ b/docs/plans/jobs/2026-02-04-fix-codex-raw-paths.md @@ -0,0 +1,12 @@ +--- +title: "Fix codex raw/absolute path handling" +date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +Adjusted codex-mode list output so: +- `--output raw` returns a relative path to `$CODEX_HOME` by default. +- `--output raw --abs` returns an absolute path (no `$CODEX_HOME` placeholder). + diff --git a/docs/plans/jobs/2026-02-04-fix-codex-raw-relative-to-cwd.md b/docs/plans/jobs/2026-02-04-fix-codex-raw-relative-to-cwd.md new file mode 100644 index 0000000..4e02dad --- /dev/null +++ b/docs/plans/jobs/2026-02-04-fix-codex-raw-relative-to-cwd.md @@ -0,0 +1,10 @@ +--- +title: "Fix codex raw paths to be relative to cwd" +date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +Adjusted codex-mode `list --output raw` to return paths relative to the current working directory when `--abs` is not set. + From 067c643dd7aaaa9af7f3ad3d9ba49140aa0c1203 Mon Sep 17 00:00:00 2001 From: nakolus Date: Wed, 4 Feb 2026 17:40:48 +0800 Subject: [PATCH 13/19] fix: return first fuzzy match for list and status --- cli/list.go | 3 +++ cli/status.go | 10 +++++++--- .../jobs/2026-02-04-fuzzy-first-match-list-status.md | 10 ++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 docs/plans/jobs/2026-02-04-fuzzy-first-match-list-status.md diff --git a/cli/list.go b/cli/list.go index eb0ece8..31cbd34 100644 --- a/cli/list.go +++ b/cli/list.go @@ -200,6 +200,9 @@ func newListCommand() *cobra.Command { continue } rows = append(rows, row) + if query != "" && !opts.strict { + break + } } if mode != modeCodex && opts.output == "raw" && len(rows) == 0 { diff --git a/cli/status.go b/cli/status.go index 3abae1a..d053abe 100644 --- a/cli/status.go +++ b/cli/status.go @@ -98,7 +98,6 @@ func newStatusCommand() *cobra.Command { if query == "" { return fmt.Errorf("task query cannot be empty") } - opts.strict = true } else { query, err = normalizeTaskQuery(args[0]) if err != nil { @@ -119,7 +118,9 @@ func newStatusCommand() *cobra.Command { } opts.strict = true } - opts.strict = true + if mode != modeCodex { + opts.strict = true + } } target := opts.target @@ -160,7 +161,7 @@ func newStatusCommand() *cobra.Command { if branch == "" { branch = "detached" } - if query != "" && task != query { + if query != "" && !matchesTask(task, query, opts.strict) { continue } } else { @@ -219,6 +220,9 @@ func newStatusCommand() *cobra.Command { Ahead: statusInfo.Ahead, Behind: statusInfo.Behind, }) + if query != "" && !opts.strict { + break + } } if mode != modeCodex && len(rows) == 0 { diff --git a/docs/plans/jobs/2026-02-04-fuzzy-first-match-list-status.md b/docs/plans/jobs/2026-02-04-fuzzy-first-match-list-status.md new file mode 100644 index 0000000..8ab7e5d --- /dev/null +++ b/docs/plans/jobs/2026-02-04-fuzzy-first-match-list-status.md @@ -0,0 +1,10 @@ +--- +title: "Make fuzzy query return first match (list/status)" +date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +When a task query is provided without `--strict`, `list` and `status` now return only the first matching worktree (classic and codex modes) for consistent fuzzy retrieval. + From 0b551386e92d26eabbe4b5d6b962a537c2f71c86 Mon Sep 17 00:00:00 2001 From: nakolus Date: Thu, 5 Feb 2026 00:07:35 +0800 Subject: [PATCH 14/19] refactor: rename Codex sync command to apply and update docs --- cli/{sync.go => apply.go} | 42 +++++++++---------- cli/finish.go | 2 +- cli/root.go | 2 +- .../2026-02-04-codex-apply-terminology.md | 24 +++++++++++ .../plan-2026-02-04-mode-classic-and-codex.md | 22 ++++++---- ...search-2026-02-04-mode-classic-vs-codex.md | 33 ++++++++------- docs/schemas/config-gwtt.md | 1 + 7 files changed, 80 insertions(+), 46 deletions(-) rename cli/{sync.go => apply.go} (86%) create mode 100644 docs/plans/jobs/2026-02-04-codex-apply-terminology.md diff --git a/cli/sync.go b/cli/apply.go similarity index 86% rename from cli/sync.go rename to cli/apply.go index c470d96..a6050ad 100644 --- a/cli/sync.go +++ b/cli/apply.go @@ -15,36 +15,36 @@ import ( "github.com/spf13/cobra" ) -type syncOptions struct { +type applyOptions struct { yes bool dryRun bool } -type syncConflictError struct { +type applyConflictError struct { reason string err error } -func (e *syncConflictError) Error() string { +func (e *applyConflictError) Error() string { if e.err == nil { return e.reason } return fmt.Sprintf("%s: %v", e.reason, e.err) } -func (e *syncConflictError) Unwrap() error { return e.err } +func (e *applyConflictError) Unwrap() error { return e.err } -func newSyncCommand() *cobra.Command { - opts := &syncOptions{} +func newApplyCommand() *cobra.Command { + opts := &applyOptions{} cmd := &cobra.Command{ - Use: "sync ", - Short: "Sync changes between a Codex worktree and the local checkout", + Use: "apply ", + Short: "Apply changes between a Codex worktree and the local checkout", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg, ok := configFromContext(ctx) if !ok || cfg.Mode != modeCodex { - return fmt.Errorf("sync is only supported in --mode=codex") + return fmt.Errorf("apply is only supported in --mode=codex") } runner := defaultRunner() @@ -79,12 +79,12 @@ func newSyncCommand() *cobra.Command { return fmt.Errorf("no Codex worktree found for %q under %s", opaqueID, filepath.Join("$CODEX_HOME", "worktrees")) } - conflictReasons, err := detectSyncConflicts(ctx, runner, repoRoot, wtPath) + conflictReasons, err := detectApplyConflicts(ctx, runner, repoRoot, wtPath) if err != nil { return err } if len(conflictReasons) > 0 { - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.WarningStyle.Render("sync conflict detected:")); err != nil { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.WarningStyle.Render("apply conflict detected:")); err != nil { return err } for _, reason := range conflictReasons { @@ -110,11 +110,11 @@ func newSyncCommand() *cobra.Command { } } - return syncOverwrite(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) + return overwriteWorktreeChanges(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) } - if err := syncApply(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun); err != nil { - var conflictErr *syncConflictError + if err := applyWorktreeChanges(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun); err != nil { + var conflictErr *applyConflictError if errors.As(err, &conflictErr) { if !opts.yes { if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.WarningStyle.Render(conflictErr.reason)); err != nil { @@ -135,11 +135,11 @@ func newSyncCommand() *cobra.Command { return errCanceled } } - return syncOverwrite(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) + return overwriteWorktreeChanges(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) } return err } - if _, err := fmt.Fprintln(cmd.OutOrStdout(), ui.SuccessStyle.Render("sync complete")); err != nil { + if _, err := fmt.Fprintln(cmd.OutOrStdout(), ui.SuccessStyle.Render("apply complete")); err != nil { return err } return nil @@ -170,7 +170,7 @@ func resolveCodexWorktreePath(ctx context.Context, runner git.Runner, repoRoot, return "", false, nil } -func detectSyncConflicts(ctx context.Context, runner git.Runner, repoRoot, worktreePath string) ([]string, error) { +func detectApplyConflicts(ctx context.Context, runner git.Runner, repoRoot, worktreePath string) ([]string, error) { var reasons []string dirty, err := isDirty(ctx, runner, repoRoot) @@ -258,7 +258,7 @@ func intersects(left, right map[string]struct{}) bool { return false } -func syncApply(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoRoot, worktreePath string, dryRun bool) error { +func applyWorktreeChanges(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoRoot, worktreePath string, dryRun bool) error { patch, err := gitDiff(ctx, runner, worktreePath) if err != nil { return err @@ -272,7 +272,7 @@ func syncApply(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoR if patch != "" { if err := runGit(ctx, cmd, dryRun, runner, "-C", repoRoot, "apply", "--check", patchFile); err != nil { - return &syncConflictError{reason: "apply patch check failed", err: err} + return &applyConflictError{reason: "apply patch check failed", err: err} } if err := runGit(ctx, cmd, dryRun, runner, "-C", repoRoot, "apply", patchFile); err != nil { return fmt.Errorf("apply patch: %w", err) @@ -292,7 +292,7 @@ func syncApply(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoR return nil } -func syncOverwrite(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoRoot, worktreePath string, dryRun bool) error { +func overwriteWorktreeChanges(ctx context.Context, cmd *cobra.Command, runner git.Runner, repoRoot, worktreePath string, dryRun bool) error { if err := runGit(ctx, cmd, dryRun, runner, "-C", worktreePath, "reset", "--hard"); err != nil { return err } @@ -363,7 +363,7 @@ func listUntracked(ctx context.Context, runner git.Runner, repoRoot string) ([]s } func writeTempPatch(contents string) (string, error) { - tmp, err := os.CreateTemp("", "gwtt-sync-*.patch") + tmp, err := os.CreateTemp("", "gwtt-apply-*.patch") if err != nil { return "", err } diff --git a/cli/finish.go b/cli/finish.go index bc0af53..bc15929 100644 --- a/cli/finish.go +++ b/cli/finish.go @@ -34,7 +34,7 @@ func newFinishCommand() *cobra.Command { ctx := cmd.Context() runner := defaultRunner() if cfg, ok := configFromContext(ctx); ok && cfg.Mode == modeCodex { - return fmt.Errorf("finish is not supported in --mode=codex (use gwtt sync or run with --mode=classic)") + return fmt.Errorf("finish is not supported in --mode=codex (use gwtt apply or run with --mode=classic)") } if cfg, ok := configFromContext(cmd.Context()); ok { if !cmd.Flags().Changed("yes") { diff --git a/cli/root.go b/cli/root.go index ce610b1..8ac84c8 100644 --- a/cli/root.go +++ b/cli/root.go @@ -123,7 +123,7 @@ func gitWorkTreeCommand() (*cobra.Command, *runState) { newCleanupCommand(), newListCommand(), newStatusCommand(), - newSyncCommand(), + newApplyCommand(), newTUICommand(), ) diff --git a/docs/plans/jobs/2026-02-04-codex-apply-terminology.md b/docs/plans/jobs/2026-02-04-codex-apply-terminology.md new file mode 100644 index 0000000..dbb4462 --- /dev/null +++ b/docs/plans/jobs/2026-02-04-codex-apply-terminology.md @@ -0,0 +1,24 @@ +--- +title: "Codex apply terminology updates" +date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +- Updated docs to align Codex App UI terminology with "Hand off changes" and clarified that app-side worktree/shell issues are out of CLI scope. +- Renamed codex-mode command references from `sync` to `apply` across research, plan, and config docs. +- Renamed CLI implementation from `sync` to `apply` (including file rename, symbols, and user-facing messages). + +## Why +- Codex App UI now uses "Hand off changes" with directions "To local" / "From local", while official docs still say "Sync with local". +- Avoid confusion with "apply" terminology in the Codex CLI by making the gwtt command name explicit and consistent with the app wording. +- Track app-side worktree issues separately from CLI responsibilities. + +## Files Updated +- `docs/research-2026-02-04-mode-classic-vs-codex.md` +- `docs/plans/plan-2026-02-04-mode-classic-and-codex.md` +- `docs/schemas/config-gwtt.md` +- `cli/apply.go` +- `cli/root.go` +- `cli/finish.go` diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md index 2844f8c..9992a25 100644 --- a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -1,6 +1,7 @@ --- title: "Mode flag: classic and codex" date: 2026-02-04 +modified-date: 2026-02-04 status: active agent: codex --- @@ -12,7 +13,8 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - `--mode` flag + `GWTT_MODE` env + config default (flag > env > config > default). - Codex-mode worktree discovery under `$CODEX_HOME/worktrees/**` with repo-scoped filtering via `git worktree list --porcelain`. - Codex-mode selection model: `` is the exact `` directory name under `$CODEX_HOME/worktrees`. -- Codex-mode `sync` command (`apply` by default; optional overwrite on conflict with a second confirmation). +- Codex-mode `apply` command (default: apply worktree -> local; optional overwrite on conflict with a second confirmation). +- UI terminology: Codex App labels the action as “Hand off changes” with directions “To local” / “From local”; official docs still say “Sync with local.” We map this to the CLI `apply` command. - Codex-mode `cleanup` that only targets `$CODEX_HOME/worktrees/` and is conservative with prominent warnings + confirmations. - Add `modified_time` to `status` output (RFC3339 UTC). @@ -28,7 +30,7 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - [x] Write a short CLI spec section (either in the research doc or a new schema doc) covering: - [x] Codex-mode worktree selection: `` resolution rules and error messages. - [x] Repo scoping strategy for `list/status` in codex mode (Git-derived, not naming-derived). - - [x] `sync` conflict detection signals and the overwrite confirmation flow (`--yes` behavior). + - [x] `apply` conflict detection signals and the overwrite confirmation flow (`--yes` behavior). - [x] `cleanup` safety model (scope restriction, warnings, second confirmation, and “restore is best-effort” note). ### Phase 2: Code Implementation @@ -48,29 +50,30 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - [x] Add `modified_time` to status rows: - [x] Use filesystem `mtime` of the worktree directory. - [x] Format as RFC3339 UTC for JSON/CSV; table uses the same value. -- [x] Add `gwtt sync ` (codex-mode only): +- [x] Add `gwtt apply ` (codex-mode only): - [x] Default to “apply” (worktree -> local checkout). - [x] Conflict detection: dirty local checkout, failed apply/merge step, and/or both sides modified the same file (where detectable). - - [x] On conflict, prompt whether to overwrite; require a second confirmation; `--yes` bypasses overwrite confirmation. - - [x] Keep behavior aligned with Codex App: ignored files are not synced. + - [x] On conflict, prompt whether to overwrite; require a second confirmation. Advanced usage: `--yes` skips prompts and proceeds to overwrite (force mode). + - [x] Keep behavior aligned with Codex App: ignored files are not transferred. - [x] Re-check `cleanup` behavior for codex mode: - [x] Restrict deletions to `$CODEX_HOME/worktrees/` only. - [x] Mirror Codex App “never clean up if …” rules when detectable; otherwise warn prominently and require a second confirmation. - [x] Document/communicate that Codex restore is best-effort (not guaranteed by `gwtt`). - [x] Ensure codex-mode `--output raw` returns a composable path (relative or absolute) rather than `$CODEX_HOME` placeholders; keep `$CODEX_HOME/...` for display formats. +- [x] Confirmed app-side worktree shell/run-script issues are out of CLI scope and tracked upstream. ### Phase 3: Unit Test Verification - [ ] Add tests for `mode` precedence and validation (flag/env/config/default). - [ ] Add tests for codex-mode list/status filtering (repo-scoped via `git worktree list` + `$CODEX_HOME/worktrees` prefix filter). - [ ] Add tests for `` derivation and path rendering (`$CODEX_HOME` display). - [ ] Add tests for `modified_time` formatting (RFC3339 UTC) and JSON/CSV output shape. -- [ ] Add tests for `sync` conflict detection and confirmation gating (including `--yes`). +- [ ] Add tests for `apply` conflict detection and confirmation gating (including `--yes`). - [ ] Add tests for codex cleanup scope restriction + confirmation flow. ### Phase 4: README / CLI Docs Update - [ ] Update `README.md`: - [ ] Document `--mode`, `GWTT_MODE`, and config `mode`. - - [ ] Add codex-mode usage examples for `list/status/sync/cleanup`. + - [ ] Add codex-mode usage examples for `list/status/apply/cleanup`. - [ ] Update `## Notes` “Global flags” list to include `--mode`. - [ ] Document `modified_time` in `status` outputs (and the fixed date format). - [ ] If applicable, update any man/help text sources under `man/` to reflect new commands/flags. @@ -82,14 +85,15 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex ## Acceptance Criteria - Default behavior (no `--mode`, no `GWTT_MODE`, no config) remains unchanged. -- `--mode=codex` enables codex-specific list/status/sync/cleanup without impacting classic users. +- `--mode=codex` enables codex-specific list/status/apply/cleanup without impacting classic users. - Codex-mode selection uses `` reliably and errors clearly when not found/ambiguous. - `status` includes `modified_time` with RFC3339 UTC formatting for machine outputs. - Codex-mode cleanup is narrowly scoped and always warns + confirms before deletion. ## Risks / Notes - Codex App “pinned/sidebar/thread linkage” signals may not be detectable from disk without reading Codex’s internal state; default to warnings + a second confirmation when uncertain. -- `sync` semantics are easy to get subtly wrong; keep the initial implementation conservative and well-tested. +- `apply` semantics are easy to get subtly wrong; keep the initial implementation conservative and well-tested. +- Track open worktree issues and shell/run-script reports: https://github.com/openai/codex/issues?q=is%3Aissue%20state%3Aopen%20worktree (example: “Worktrees keep forgetting the "Run" script”, Feb 3, 2026). ## Related Research - `docs/research-2026-02-04-mode-classic-vs-codex.md` diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md index 3ec4999..a790e28 100644 --- a/docs/research-2026-02-04-mode-classic-vs-codex.md +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -19,14 +19,15 @@ Define what a new global `--mode` flag should mean for this CLI, so we can suppo - **Paths are displayed** relative to the repo root by default; `--abs`/`--absolute-path` shows absolute paths. ### Codex App worktree behavior (“codex”) -Based on Codex App documentation, the worktree model is intentionally different from this CLI’s task/branch model: +Based on Codex App documentation (and current app UI), the worktree model is intentionally different from this CLI’s task/branch model: - **Worktree location is not per-worktree user-chosen**: worktrees are created under `$CODEX_HOME/worktrees` so the app can manage them consistently. - **Worktrees start in detached HEAD** by default (to avoid Git’s restriction that a branch cannot be checked out in two worktrees at once). - **Local changes may be applied** when the worktree is created from an existing local branch with uncommitted changes. -- **Sync is a first-class operation** for getting changes between the local checkout and the worktree: - - “Apply” worktree changes into local checkout. - - “Overwrite” worktree from local checkout. +- **“Hand off changes” is a first-class operation** for getting changes between the local checkout and the worktree: + - UI labels are now “Hand off changes” with directions “To local” / “From local”. + - The official docs still use “Sync with local” for the same action and describe “Apply” and “Overwrite” modes. - Sync does not transfer ignored files (and the resulting state may not match a full re-clone). + - Terminology overlap: “Apply” also exists in the Codex CLI as `codex apply ` (Codex Cloud task diff). This can be confusing when discussing “apply” in the app UI vs the CLI. - **Worktree restoration** is a distinct concept (recreate a worktree from a Codex snapshot, rather than from the current local checkout). - **Cleanup is app-governed and tied to threads**: the Codex app cleans up worktrees when you archive threads (or on startup for worktrees with no associated threads), and it preserves a snapshot for later restore. @@ -44,9 +45,9 @@ To keep `classic` stable and keep `codex` aligned with Codex App: - Example path: `~/.codex/worktrees/bf15/git-worktree-tasks` - `` is `bf15` (the opaque ID), **not** `git-worktree-tasks`. - **Create is detached-only:** in `codex` mode, `create` should not offer a `--branch` escape hatch; the default stays detached to avoid future complexity. -- **Finish is classic-only:** in `codex` mode, `finish` is not a good fit; use a dedicated `sync` command instead. +- **Finish is classic-only:** in `codex` mode, `finish` is not a good fit; use a dedicated `apply` command instead. - **Cleanup follows current mode:** `gwtt cleanup` should operate on the worktrees owned by the active mode (`classic` naming vs `$CODEX_HOME/worktrees`), rather than mixing behaviors. -- **Sync UX:** `gwtt sync ` defaults to “apply”; if a conflict is detected, prompt to “overwrite” (second confirmation), skippable with `--yes`. +- **Apply UX:** `gwtt apply ` defaults to “apply”; if a conflict is detected, prompt to “overwrite” (second confirmation), skippable with `--yes`. - **Cleanup in codex mode:** free disk by deleting the on-disk directory under `$CODEX_HOME/worktrees/`, but only when it is safe: - Skip worktrees that fall under Codex App’s “never clean up if …” restrictions. - If we cannot verify a restriction (e.g., pinned/sidebar linkage), still allow deletion but show a prominent warning and require a second confirmation (skippable with `--yes`). @@ -55,7 +56,7 @@ To keep `classic` stable and keep `codex` aligned with Codex App: ### Practical restrictions implied by `--mode=codex` - **No “task branch” assumption:** detached worktrees mean we can’t infer branch names from task names. - **No arbitrary `--path` override:** codex-mode worktrees live under `$CODEX_HOME/worktrees`; allowing arbitrary paths would complicate cleanup, display, and registry invariants. -- **Different command surface:** branch-merge workflows (`finish`) are replaced by sync workflows (`sync apply` / `sync overwrite`). +- **Different command surface:** branch-merge workflows (`finish`) are replaced by apply workflows (`apply` / `overwrite`). - **Cleanup restrictions from Codex App:** Codex App’s auto-cleanup is disabled in some cases (e.g., pinned conversation, added to sidebar, age > 4 days, worktree count > 10). - Note: the “age > 4 days” / “count > 10” conditions are counterintuitive, but this is the wording in the official docs as of 2026-02-04. - **Detached HEAD as codex marker:** codex-mode worktrees are detached; classic-mode worktrees are expected to be on a branch. Use this to keep classic commands from “seeing” codex worktrees. @@ -68,10 +69,10 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif ## Implications or Recommendations - Add a global flag `--mode` with values: - `classic` (default): current behavior and naming convention. - - `codex`: Codex App-aligned behavior (detached worktrees, `$CODEX_HOME` root, ID-based mapping, sync-oriented workflow). + - `codex`: Codex App-aligned behavior (detached worktrees, `$CODEX_HOME` root, ID-based mapping, apply-oriented workflow). - Treat `codex` mode as additive: - Keep existing commands and semantics intact in `classic`. - - Introduce new behavior behind `--mode=codex` (and/or new codex-only subcommands like `sync`) rather than changing defaults. + - Introduce new behavior behind `--mode=codex` (and/or new codex-only subcommands like `apply`) rather than changing defaults. - Define a “codex worktree root”: - In codex mode, treat `$CODEX_HOME/worktrees` as the only allowable root for “managed” worktrees. - Introduce a path rendering helper that can: @@ -117,14 +118,14 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif - Filter entries whose worktree path is under `$CODEX_HOME/worktrees/`. - Do not attempt to infer repo identity from `` naming. -### `sync` (codex mode) -- CLI: `gwtt sync ` (default operation: apply worktree changes into the local checkout). +### `apply` (codex mode) +- CLI: `gwtt apply ` (default operation: apply worktree changes into the local checkout). - Conflict detection signals (predictable, conservative): - Local checkout is dirty, or - the apply/merge step fails, or - both sides modified the same file (where detectable). - On conflict: prompt whether to “overwrite” (local -> worktree) and require a second confirmation; `--yes` bypasses the overwrite confirmation. -- Keep Codex App parity: ignored files are not synced. +- Keep Codex App parity: ignored files are not transferred. ### `cleanup` (codex mode) - Scope: only delete the on-disk directory at `$CODEX_HOME/worktrees/` (free disk; do not touch classic paths). @@ -144,11 +145,15 @@ Codex App uses a “variable-aware” presentation of paths (and the user specif ## Open Questions (Remaining) - Can we (safely) detect any Codex cleanup-restriction signals from disk without coupling `gwtt` to Codex’s internal storage formats? - Likely no; default to warnings + a second confirmation (skippable with `--yes`) for codex cleanup. -- What is the most user-friendly confirmation wording for “overwrite” (sync) and “yolo delete” (cleanup) that still prevents accidents? +- What is the most user-friendly confirmation wording for “overwrite” (apply) and “yolo delete” (cleanup) that still prevents accidents? - Pending: depends on user confidence that Codex can restore a worktree after manual deletion (docs only guarantee snapshots before **Codex-managed** cleanup). ## Notes -- Restoration remains out of scope: keep to create/sync/list/status only for now; a future `restore` likely needs to integrate with Codex App snapshot state. +- Restoration remains out of scope: keep to create/apply/list/status only for now; a future `restore` likely needs to integrate with Codex App snapshot state. + +## Open Issues (Worktrees + Shell/Run Scripts) +- App-side issues only (not CLI behavior). Track open worktree-related issues: https://github.com/openai/codex/issues?q=is%3Aissue%20state%3Aopen%20worktree +- Recent example: “Codex app: Worktrees keep forgetting the "Run" script” (open, Feb 3, 2026). https://github.com/openai/codex/issues/10476 ## References - Codex App worktrees documentation (includes cleanup + FAQ): https://developers.openai.com/codex/app/worktrees/ diff --git a/docs/schemas/config-gwtt.md b/docs/schemas/config-gwtt.md index 7c1d20a..8813c47 100644 --- a/docs/schemas/config-gwtt.md +++ b/docs/schemas/config-gwtt.md @@ -87,6 +87,7 @@ Define the authoritative configuration schema for `gwtt`, including keys, types, ## Decisions - `create.path.format` must include `{task}` to preserve task discovery. - `merge_mode` is exclusive; only one strategy may be active at a time. +- Codex-mode uses an `apply` command for hand-off changes; there are no config keys for it yet. - No config defaults for `create.base` or `status/finish.target`. ## Examples From 973dd42be29e5639cd49f54f28fca8c1617b9dd7 Mon Sep 17 00:00:00 2001 From: nakolus Date: Thu, 5 Feb 2026 01:10:59 +0800 Subject: [PATCH 15/19] fix: add tests for codex/mode and fix temp file cleanup --- cli/apply.go | 43 +++- cli/apply_test.go | 63 +++++ cli/codex_test.go | 41 ++++ cli/integration_test.go | 220 +++++++++++++++++- cli/list.go | 8 +- cli/mode_test.go | 137 +++++++++++ cli/root.go | 2 +- cli/status.go | 8 +- docs/plans/jobs/2026-02-04-phase-3-tests.md | 14 ++ .../plan-2026-02-04-mode-classic-and-codex.md | 12 +- 10 files changed, 518 insertions(+), 30 deletions(-) create mode 100644 cli/apply_test.go create mode 100644 cli/codex_test.go create mode 100644 cli/mode_test.go create mode 100644 docs/plans/jobs/2026-02-04-phase-3-tests.md diff --git a/cli/apply.go b/cli/apply.go index a6050ad..0654c07 100644 --- a/cli/apply.go +++ b/cli/apply.go @@ -268,7 +268,11 @@ func applyWorktreeChanges(ctx context.Context, cmd *cobra.Command, runner git.Ru if err != nil { return err } - defer os.Remove(patchFile) + defer func() { + if err := removeTempPatch(patchFile); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: failed to remove temp patch %s: %v\n", patchFile, err) + } + }() if patch != "" { if err := runGit(ctx, cmd, dryRun, runner, "-C", repoRoot, "apply", "--check", patchFile); err != nil { @@ -308,7 +312,11 @@ func overwriteWorktreeChanges(ctx context.Context, cmd *cobra.Command, runner gi if err != nil { return err } - defer os.Remove(patchFile) + defer func() { + if err := removeTempPatch(patchFile); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: failed to remove temp patch %s: %v\n", patchFile, err) + } + }() if patch != "" { if err := runGit(ctx, cmd, dryRun, runner, "-C", worktreePath, "apply", patchFile); err != nil { @@ -367,14 +375,19 @@ func writeTempPatch(contents string) (string, error) { if err != nil { return "", err } - defer tmp.Close() if _, err := io.WriteString(tmp, contents); err != nil { + if closeErr := tmp.Close(); closeErr != nil { + return "", fmt.Errorf("write patch: %w (close error: %v)", err, closeErr) + } + return "", err + } + if err := tmp.Close(); err != nil { return "", err } return tmp.Name(), nil } -func copyFile(srcRoot, dstRoot, rel string, dryRun bool, out io.Writer) error { +func copyFile(srcRoot, dstRoot, rel string, dryRun bool, out io.Writer) (err error) { if dryRun { _, err := fmt.Fprintf(out, "copy %s -> %s\n", filepath.Join(srcRoot, rel), filepath.Join(dstRoot, rel)) return err @@ -389,7 +402,11 @@ func copyFile(srcRoot, dstRoot, rel string, dryRun bool, out io.Writer) error { if err != nil { return err } - defer in.Close() + defer func() { + if closeErr := in.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() info, err := in.Stat() if err != nil { @@ -400,10 +417,24 @@ func copyFile(srcRoot, dstRoot, rel string, dryRun bool, out io.Writer) error { if err != nil { return err } - defer outFile.Close() + defer func() { + if closeErr := outFile.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() if _, err := io.Copy(outFile, in); err != nil { return err } return nil } + +func removeTempPatch(path string) error { + if strings.TrimSpace(path) == "" { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/cli/apply_test.go b/cli/apply_test.go new file mode 100644 index 0000000..15be2d5 --- /dev/null +++ b/cli/apply_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "context" + "strings" + "testing" +) + +func TestDetectApplyConflicts(t *testing.T) { + runner := fakeRunner{ + responses: map[string]fakeResponse{ + "-C /repo status --porcelain": {stdout: " M file.txt\n"}, + "-C /repo diff --name-only HEAD": {stdout: "file.txt\n"}, + "-C /repo ls-files --others --exclude-standard": {stdout: ""}, + "-C /codex diff --name-only HEAD": {stdout: "file.txt\n"}, + "-C /codex ls-files --others --exclude-standard": {stdout: ""}, + }, + } + + reasons, err := detectApplyConflicts(context.Background(), runner, "/repo", "/codex") + if err != nil { + t.Fatalf("detectApplyConflicts error: %v", err) + } + if len(reasons) != 2 { + t.Fatalf("expected 2 conflict reasons, got %d", len(reasons)) + } + + var hasDirty, hasOverlap bool + for _, reason := range reasons { + if strings.Contains(reason, "uncommitted changes") { + hasDirty = true + } + if strings.Contains(reason, "both sides modified") { + hasOverlap = true + } + } + if !hasDirty { + t.Fatalf("expected uncommitted changes reason, got %v", reasons) + } + if !hasOverlap { + t.Fatalf("expected overlap reason, got %v", reasons) + } +} + +func TestDetectApplyConflictsNone(t *testing.T) { + runner := fakeRunner{ + responses: map[string]fakeResponse{ + "-C /repo status --porcelain": {stdout: ""}, + "-C /repo diff --name-only HEAD": {stdout: "local.txt\n"}, + "-C /repo ls-files --others --exclude-standard": {stdout: ""}, + "-C /codex diff --name-only HEAD": {stdout: "other.txt\n"}, + "-C /codex ls-files --others --exclude-standard": {stdout: ""}, + }, + } + + reasons, err := detectApplyConflicts(context.Background(), runner, "/repo", "/codex") + if err != nil { + t.Fatalf("detectApplyConflicts error: %v", err) + } + if len(reasons) != 0 { + t.Fatalf("expected no conflict reasons, got %v", reasons) + } +} diff --git a/cli/codex_test.go b/cli/codex_test.go new file mode 100644 index 0000000..5a7a3dd --- /dev/null +++ b/cli/codex_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "path/filepath" + "testing" +) + +func TestCodexWorktreeInfo(t *testing.T) { + root := filepath.Join(t.TempDir(), "codex", "worktrees") + worktreePath := filepath.Join(root, "bf15", "repo") + + opaqueID, rel, ok := codexWorktreeInfo(root, worktreePath) + if !ok { + t.Fatalf("expected codex worktree info to be detected") + } + if opaqueID != "bf15" { + t.Fatalf("opaque id = %q, want %q", opaqueID, "bf15") + } + if rel != filepath.Join("bf15", "repo") { + t.Fatalf("relative path = %q, want %q", rel, filepath.Join("bf15", "repo")) + } + + if _, _, ok := codexWorktreeInfo(root, root); ok { + t.Fatalf("expected root path to be rejected") + } + if _, _, ok := codexWorktreeInfo(root, filepath.Join(t.TempDir(), "other")); ok { + t.Fatalf("expected outside path to be rejected") + } +} + +func TestDisplayPathForModeCodex(t *testing.T) { + repoRoot := filepath.Join(t.TempDir(), "repo") + codexHome := filepath.Join(t.TempDir(), "codex") + worktreePath := filepath.Join(codexHome, "worktrees", "bf15", "repo") + + got := displayPathForMode(repoRoot, worktreePath, false, modeCodex, codexHome) + want := filepath.Join("$CODEX_HOME", "worktrees", "bf15", "repo") + if got != want { + t.Fatalf("display path = %q, want %q", got, want) + } +} diff --git a/cli/integration_test.go b/cli/integration_test.go index 6bd7989..999fe75 100644 --- a/cli/integration_test.go +++ b/cli/integration_test.go @@ -2,6 +2,7 @@ package cli_test import ( "bytes" + "encoding/csv" "encoding/json" "errors" "os" @@ -9,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/pi2pie/git-worktree-tasks/cli" ) @@ -22,15 +24,16 @@ type listRow struct { } type statusRow struct { - Task string `json:"task"` - Branch string `json:"branch"` - Path string `json:"path"` - Base string `json:"base"` - Target string `json:"target"` - LastCommit string `json:"last_commit"` - Dirty bool `json:"dirty"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` + Task string `json:"task"` + Branch string `json:"branch"` + Path string `json:"path"` + ModifiedTime string `json:"modified_time"` + Base string `json:"base"` + Target string `json:"target"` + LastCommit string `json:"last_commit"` + Dirty bool `json:"dirty"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` } func TestIntegrationCreateListStatusFinish(t *testing.T) { @@ -67,6 +70,16 @@ func TestIntegrationCreateListStatusFinish(t *testing.T) { if statusRows[0].LastCommit == "" { t.Fatalf("expected last_commit to be populated, got empty") } + if statusRows[0].ModifiedTime == "" { + t.Fatalf("expected modified_time to be populated, got empty") + } + parsedModified, err := time.Parse(time.RFC3339, statusRows[0].ModifiedTime) + if err != nil { + t.Fatalf("parse modified_time: %v", err) + } + if parsedModified.Location() != time.UTC { + t.Fatalf("expected modified_time in UTC, got %s", parsedModified.Location()) + } writeFile(t, absWorktreePath, "task.txt", "task change\n") runGit(t, absWorktreePath, "add", "task.txt") @@ -97,6 +110,47 @@ func TestIntegrationStatusNoCommits(t *testing.T) { if rows[0].Base != "empty history" { t.Fatalf("expected base empty history, got %q", rows[0].Base) } + if rows[0].ModifiedTime == "" { + t.Fatalf("expected modified_time to be populated, got empty") + } + parsedModified, err := time.Parse(time.RFC3339, rows[0].ModifiedTime) + if err != nil { + t.Fatalf("parse modified_time: %v", err) + } + if parsedModified.Location() != time.UTC { + t.Fatalf("expected modified_time in UTC, got %s", parsedModified.Location()) + } +} + +func TestIntegrationStatusCsvIncludesModifiedTime(t *testing.T) { + repoDir := initRepo(t, true) + statusOutput := runCLI(t, repoDir, "", "--nocolor", "status", "--output", "csv") + reader := csv.NewReader(strings.NewReader(statusOutput)) + header, err := reader.Read() + if err != nil { + t.Fatalf("read csv header: %v", err) + } + modifiedIndex := indexOf(header, "modified_time") + if modifiedIndex == -1 { + t.Fatalf("expected modified_time column in header, got %v", header) + } + record, err := reader.Read() + if err != nil { + t.Fatalf("read csv record: %v", err) + } + if len(record) != len(header) { + t.Fatalf("csv record length %d != header length %d", len(record), len(header)) + } + if record[modifiedIndex] == "" { + t.Fatalf("expected modified_time column to be populated, got empty") + } + parsedModified, err := time.Parse(time.RFC3339, record[modifiedIndex]) + if err != nil { + t.Fatalf("parse modified_time: %v", err) + } + if parsedModified.Location() != time.UTC { + t.Fatalf("expected modified_time in UTC, got %s", parsedModified.Location()) + } } func TestIntegrationDetachedHeadAndPrunableCleanup(t *testing.T) { @@ -142,6 +196,116 @@ func TestIntegrationDetachedHeadAndPrunableCleanup(t *testing.T) { } } +func TestIntegrationCodexListStatusFiltering(t *testing.T) { + repoDir := initRepo(t, true) + codexHome := setCodexHome(t) + opaqueID := "bf15" + addCodexWorktree(t, repoDir, codexHome, opaqueID) + + listOutput := runCLI(t, repoDir, "", "--nocolor", "--mode", "codex", "list", "--output", "json") + var listRows []listRow + if err := json.Unmarshal([]byte(listOutput), &listRows); err != nil { + t.Fatalf("parse list json: %v", err) + } + if len(listRows) != 1 { + t.Fatalf("expected 1 codex list row, got %d", len(listRows)) + } + if listRows[0].Task != opaqueID { + t.Fatalf("expected codex task %q, got %q", opaqueID, listRows[0].Task) + } + wantPath := filepath.Join("$CODEX_HOME", "worktrees", opaqueID, filepath.Base(repoDir)) + if listRows[0].Path != wantPath { + t.Fatalf("expected codex path %q, got %q", wantPath, listRows[0].Path) + } + + statusOutput := runCLI(t, repoDir, "", "--nocolor", "--mode", "codex", "status", "--output", "json") + var statusRows []statusRow + if err := json.Unmarshal([]byte(statusOutput), &statusRows); err != nil { + t.Fatalf("parse status json: %v", err) + } + if len(statusRows) != 1 { + t.Fatalf("expected 1 codex status row, got %d", len(statusRows)) + } + if statusRows[0].Task != opaqueID { + t.Fatalf("expected codex task %q, got %q", opaqueID, statusRows[0].Task) + } + if statusRows[0].Path != wantPath { + t.Fatalf("expected codex path %q, got %q", wantPath, statusRows[0].Path) + } + + classicListOutput := runCLI(t, repoDir, "", "--nocolor", "list", "--output", "json") + var classicRows []listRow + if err := json.Unmarshal([]byte(classicListOutput), &classicRows); err != nil { + t.Fatalf("parse classic list json: %v", err) + } + for _, row := range classicRows { + if row.Task == opaqueID { + t.Fatalf("expected codex worktree to be filtered in classic mode") + } + } +} + +func TestIntegrationApplyConflictConfirmation(t *testing.T) { + repoDir := initRepo(t, true) + codexHome := setCodexHome(t) + opaqueID := "apply01" + codexPath := addCodexWorktree(t, repoDir, codexHome, opaqueID) + + writeFile(t, repoDir, "shared.txt", "local change\n") + writeFile(t, codexPath, "shared.txt", "codex change\n") + + _, err := runCLIError(t, repoDir, "no\n", "--nocolor", "--mode", "codex", "apply", opaqueID) + if err == nil || !strings.Contains(err.Error(), "canceled") { + t.Fatalf("expected apply to be canceled, got %v", err) + } + + content, err := os.ReadFile(filepath.Join(codexPath, "shared.txt")) + if err != nil { + t.Fatalf("read codex file: %v", err) + } + if string(content) != "codex change\n" { + t.Fatalf("expected codex content to remain, got %q", string(content)) + } + + runCLI(t, repoDir, "", "--nocolor", "--mode", "codex", "apply", opaqueID, "--yes") + content, err = os.ReadFile(filepath.Join(codexPath, "shared.txt")) + if err != nil { + t.Fatalf("read codex file after apply: %v", err) + } + if string(content) != "local change\n" { + t.Fatalf("expected codex content to be overwritten, got %q", string(content)) + } +} + +func TestIntegrationCodexCleanupScopeAndConfirm(t *testing.T) { + repoDir := initRepo(t, true) + codexHome := setCodexHome(t) + opaqueID := "clean01" + codexPath := addCodexWorktree(t, repoDir, codexHome, opaqueID) + classicPath := addClassicWorktree(t, repoDir, "classic-task") + + _, err := runCLIError(t, repoDir, "", "--nocolor", "--mode", "codex", "cleanup", opaqueID, "--remove-branch") + if err == nil || !strings.Contains(err.Error(), "branch cleanup is not supported") { + t.Fatalf("expected codex branch cleanup error, got %v", err) + } + + _, err = runCLIError(t, repoDir, "yes\nno\n", "--nocolor", "--mode", "codex", "cleanup", opaqueID) + if err == nil || !strings.Contains(err.Error(), "canceled") { + t.Fatalf("expected codex cleanup to be canceled, got %v", err) + } + if _, err := os.Stat(codexPath); err != nil { + t.Fatalf("expected codex worktree to remain after cancel: %v", err) + } + + runCLI(t, repoDir, "", "--nocolor", "--mode", "codex", "cleanup", opaqueID, "--yes") + if _, err := os.Stat(codexPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected codex worktree removed, stat error: %v", err) + } + if _, err := os.Stat(classicPath); err != nil { + t.Fatalf("expected classic worktree to remain, stat error: %v", err) + } +} + func initRepo(t *testing.T, withCommit bool) string { t.Helper() root := t.TempDir() @@ -189,6 +353,7 @@ func runCLIWithErr(t *testing.T, cwd string, input string, args ...string) (stri t.Fatalf("chdir: %v", err) } t.Setenv("GWTT_THEME", "default") + t.Setenv("HOME", t.TempDir()) cmd := cli.RootCommand() var outBuf bytes.Buffer @@ -234,3 +399,40 @@ func branchExists(t *testing.T, dir, branch string) bool { output := runGit(t, dir, "branch", "--list", branch) return strings.TrimSpace(output) != "" } + +func setCodexHome(t *testing.T) string { + t.Helper() + codexHome := t.TempDir() + if resolved, err := filepath.EvalSymlinks(codexHome); err == nil { + codexHome = resolved + } + t.Setenv("CODEX_HOME", codexHome) + return codexHome +} + +func addCodexWorktree(t *testing.T, repoDir, codexHome, opaqueID string) string { + t.Helper() + worktreesRoot := filepath.Join(codexHome, "worktrees", opaqueID) + if err := os.MkdirAll(worktreesRoot, 0o755); err != nil { + t.Fatalf("mkdir codex worktrees: %v", err) + } + worktreePath := filepath.Join(worktreesRoot, filepath.Base(repoDir)) + runGit(t, repoDir, "worktree", "add", "--detach", worktreePath) + return worktreePath +} + +func addClassicWorktree(t *testing.T, repoDir, branch string) string { + t.Helper() + worktreePath := filepath.Join(filepath.Dir(repoDir), filepath.Base(repoDir)+"_"+branch) + runGit(t, repoDir, "worktree", "add", "-b", branch, worktreePath) + return worktreePath +} + +func indexOf(values []string, target string) int { + for i, value := range values { + if value == target { + return i + } + } + return -1 +} diff --git a/cli/list.go b/cli/list.go index 31cbd34..e5c0c52 100644 --- a/cli/list.go +++ b/cli/list.go @@ -128,7 +128,7 @@ func newListCommand() *cobra.Command { rows := make([]listRow, 0, len(worktrees)) for _, wt := range worktrees { branch := strings.TrimPrefix(wt.Branch, "refs/heads/") - task := "-" + var task string var codexRel string var wtAbs string var err error @@ -157,9 +157,9 @@ func newListCommand() *cobra.Command { } } task, _ = worktree.TaskFromPath(repo, wt.Path) - if task == "" { - task = "-" - } + } + if task == "" { + task = "-" } row := listRow{ Task: task, diff --git a/cli/mode_test.go b/cli/mode_test.go new file mode 100644 index 0000000..ee51d88 --- /dev/null +++ b/cli/mode_test.go @@ -0,0 +1,137 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestModePrecedence(t *testing.T) { + t.Run("default", func(t *testing.T) { + project := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GWTT_MODE", "") + + got, err := runModeCommand(t, project) + if err != nil { + t.Fatalf("run command: %v", err) + } + if got != modeClassic { + t.Fatalf("mode = %q, want %q", got, modeClassic) + } + }) + + t.Run("config", func(t *testing.T) { + project := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GWTT_MODE", "") + writeConfig(t, project, `mode = "codex"`) + + got, err := runModeCommand(t, project) + if err != nil { + t.Fatalf("run command: %v", err) + } + if got != modeCodex { + t.Fatalf("mode = %q, want %q", got, modeCodex) + } + }) + + t.Run("env_over_config", func(t *testing.T) { + project := t.TempDir() + t.Setenv("HOME", t.TempDir()) + writeConfig(t, project, `mode = "classic"`) + t.Setenv("GWTT_MODE", "codex") + + got, err := runModeCommand(t, project) + if err != nil { + t.Fatalf("run command: %v", err) + } + if got != modeCodex { + t.Fatalf("mode = %q, want %q", got, modeCodex) + } + }) + + t.Run("flag_over_env", func(t *testing.T) { + project := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GWTT_MODE", "codex") + + got, err := runModeCommand(t, project, "--mode", "classic") + if err != nil { + t.Fatalf("run command: %v", err) + } + if got != modeClassic { + t.Fatalf("mode = %q, want %q", got, modeClassic) + } + }) +} + +func TestModeValidation(t *testing.T) { + project := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GWTT_MODE", "nope") + + if _, err := runModeCommand(t, project); err == nil || !strings.Contains(err.Error(), "unsupported mode") { + t.Fatalf("expected unsupported mode error, got %v", err) + } + + t.Setenv("GWTT_MODE", "") + if _, err := runModeCommand(t, project, "--mode", "nope"); err == nil || !strings.Contains(err.Error(), "unsupported mode") { + t.Fatalf("expected unsupported mode error, got %v", err) + } +} + +func runModeCommand(t *testing.T, cwd string, args ...string) (string, error) { + t.Helper() + cmd, _ := gitWorkTreeCommand() + var got string + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.AddCommand(&cobra.Command{ + Use: "inspect", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, ok := configFromContext(cmd.Context()) + if !ok { + return fmt.Errorf("config missing from context") + } + got = cfg.Mode + return nil + }, + }) + cmd.SetArgs(append(args, "inspect")) + + restore := chdir(t, cwd) + defer restore() + + err := cmd.Execute() + return got, err +} + +func writeConfig(t *testing.T, dir, content string) { + t.Helper() + path := filepath.Join(dir, "gwtt.config.toml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func chdir(t *testing.T, dir string) func() { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + return func() { + if err := os.Chdir(wd); err != nil { + t.Fatalf("restore chdir: %v", err) + } + } +} diff --git a/cli/root.go b/cli/root.go index 8ac84c8..8e5f946 100644 --- a/cli/root.go +++ b/cli/root.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var Version = "0.1.1-alpha.1" +var Version = "0.1.1-alpha.2" var ( errCanceled = errors.New("git worktree task process canceled") diff --git a/cli/status.go b/cli/status.go index d053abe..75f7895 100644 --- a/cli/status.go +++ b/cli/status.go @@ -145,7 +145,7 @@ func newStatusCommand() *cobra.Command { rows := make([]statusRow, 0, len(worktrees)) for _, wt := range worktrees { branch := strings.TrimPrefix(wt.Branch, "refs/heads/") - task := "-" + var task string var wtAbs string if mode == modeCodex { var err error @@ -176,13 +176,13 @@ func newStatusCommand() *cobra.Command { } } task, _ = worktree.TaskFromPath(repo, wt.Path) - if task == "" { - task = "-" - } if query != "" && !matchesTask(task, query, opts.strict) { continue } } + if task == "" { + task = "-" + } if opts.branch != "" && branch != opts.branch { continue } diff --git a/docs/plans/jobs/2026-02-04-phase-3-tests.md b/docs/plans/jobs/2026-02-04-phase-3-tests.md new file mode 100644 index 0000000..c877aa4 --- /dev/null +++ b/docs/plans/jobs/2026-02-04-phase-3-tests.md @@ -0,0 +1,14 @@ +--- +title: "Phase 3 tests for codex mode" +date: 2026-02-04 +modified-date: 2026-02-04 +status: completed +agent: codex +--- + +## Summary +- Added unit tests for mode precedence/validation, codex worktree parsing, and apply conflict detection. +- Added integration tests for codex list/status filtering, apply confirmation gating, codex cleanup scope/confirmation, and modified_time outputs. +- Added CSV output validation for the modified_time field. +- Normalized test environment with isolated `HOME` and resolved `CODEX_HOME` symlinks to avoid host config leakage. +- Fixed lint issues: explicit temp patch cleanup handling, checked file close errors, and removed ineffectual task initialization. diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md index 9992a25..7a4444d 100644 --- a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -63,12 +63,12 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - [x] Confirmed app-side worktree shell/run-script issues are out of CLI scope and tracked upstream. ### Phase 3: Unit Test Verification -- [ ] Add tests for `mode` precedence and validation (flag/env/config/default). -- [ ] Add tests for codex-mode list/status filtering (repo-scoped via `git worktree list` + `$CODEX_HOME/worktrees` prefix filter). -- [ ] Add tests for `` derivation and path rendering (`$CODEX_HOME` display). -- [ ] Add tests for `modified_time` formatting (RFC3339 UTC) and JSON/CSV output shape. -- [ ] Add tests for `apply` conflict detection and confirmation gating (including `--yes`). -- [ ] Add tests for codex cleanup scope restriction + confirmation flow. +- [x] Add tests for `mode` precedence and validation (flag/env/config/default). +- [x] Add tests for codex-mode list/status filtering (repo-scoped via `git worktree list` + `$CODEX_HOME/worktrees` prefix filter). +- [x] Add tests for `` derivation and path rendering (`$CODEX_HOME` display). +- [x] Add tests for `modified_time` formatting (RFC3339 UTC) and JSON/CSV output shape. +- [x] Add tests for `apply` conflict detection and confirmation gating (including `--yes`). +- [x] Add tests for codex cleanup scope restriction + confirmation flow. ### Phase 4: README / CLI Docs Update - [ ] Update `README.md`: From 3472f06aa9b6549a0112f02dd3f00bb22e3afe5a Mon Sep 17 00:00:00 2001 From: nakolus Date: Thu, 5 Feb 2026 01:16:38 +0800 Subject: [PATCH 16/19] docs: add codex mode and apply documentation --- README.md | 42 +++++++++++++++-- docs/plans/jobs/2026-02-04-phase-3-tests.md | 1 + .../plan-2026-02-04-mode-classic-and-codex.md | 20 ++++---- ...search-2026-02-04-mode-classic-vs-codex.md | 2 +- man/man1/git-worktree-tasks.1 | 8 +++- man/man1/git-worktree-tasks_apply.1 | 47 +++++++++++++++++++ man/man1/git-worktree-tasks_cleanup.1 | 6 ++- man/man1/git-worktree-tasks_create.1 | 6 ++- man/man1/git-worktree-tasks_finish.1 | 6 ++- man/man1/git-worktree-tasks_list.1 | 6 ++- man/man1/git-worktree-tasks_status.1 | 6 ++- man/man1/gwtt.1 | 8 +++- man/man1/gwtt_apply.1 | 47 +++++++++++++++++++ man/man1/gwtt_cleanup.1 | 6 ++- man/man1/gwtt_create.1 | 6 ++- man/man1/gwtt_finish.1 | 6 ++- man/man1/gwtt_list.1 | 6 ++- man/man1/gwtt_status.1 | 6 ++- 18 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 man/man1/git-worktree-tasks_apply.1 create mode 100644 man/man1/gwtt_apply.1 diff --git a/README.md b/README.md index 9735943..792e2d2 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ ln -s $(which git-worktree-tasks) $(dirname $(which git-worktree-tasks))/gwtt Settings resolve in this order (highest precedence first): -1. `--theme` flag +1. `--theme` / `--mode` flags 2. Environment variables 3. Project config (`gwtt.config.toml` or `gwtt.toml` in repo root) 4. User config (`$HOME/.config/gwtt/config.toml`) @@ -178,6 +178,9 @@ export GWTT_THEME=nord # Disable color output export GWTT_COLOR=0 +# Mode selection +export GWTT_MODE=codex + # List available themes gwtt --themes ``` @@ -189,6 +192,12 @@ gwtt --themes name = "nord" ``` +### Mode Selection + +```toml +mode = "classic" # or "codex" +``` + ### Other Defaults Common defaults you can set once: @@ -248,6 +257,7 @@ name = "nord" | Command | Alias | Description | |-----------|-------|-------------| +| `apply` | | Apply Codex worktree changes to the local checkout (codex mode only) | | `create` | | Create a worktree and branch for a task | | `list` | `ls` | List task worktrees | | `status` | | Show detailed worktree status | @@ -306,6 +316,9 @@ gwtt list --abs # Grid borders in table gwtt list --grid + +# Codex mode: list Codex-managed worktrees for this repo +gwtt --mode codex list ``` **Flags:** @@ -332,9 +345,12 @@ gwtt status --target main # Filter by exact task name gwtt status --task "my-task" + +# Codex mode: show Codex-managed worktree status +gwtt --mode codex status ``` -**Status columns:** Task, Branch, Path, Base, Target, Last Commit, Dirty, Ahead, Behind +**Status columns:** Task, Branch, Path, Modified Time (RFC3339 UTC), Base, Target, Last Commit, Dirty, Ahead, Behind **Flags:** | Flag | Short | Description | @@ -379,6 +395,23 @@ gwtt finish "my-task" --cleanup --yes | `--yes` | Skip confirmation prompts | | `--dry-run` | Show git commands without executing | +### Applying Changes (Codex Mode) + +```bash +# Apply Codex worktree changes to local checkout +gwtt --mode codex apply + +# Overwrite Codex worktree from local checkout without prompts +gwtt --mode codex apply --yes + +# Preview without executing +gwtt --mode codex apply --dry-run +``` + +**Notes:** +- In codex mode, `` is the directory directly under `$CODEX_HOME/worktrees`. +- If conflicts are detected, `gwtt` prompts to overwrite the Codex worktree (second confirmation). `--yes` skips prompts. + ### Cleanup ```bash @@ -396,6 +429,9 @@ gwtt cleanup "my-task" --yes # Preview without executing gwtt cleanup "my-task" --dry-run + +# Codex mode: remove a Codex-managed worktree by opaque id +gwtt --mode codex cleanup ``` **Flags:** @@ -601,4 +637,4 @@ This project is licensed under the MIT License — see the [LICENSE](https://git - Task names are slugified (lowercase, hyphens replace spaces) - Paths are relative by default; use `--abs` for absolute - Use `--dry-run` to preview git commands -- Global flags: `--theme`, `--nocolor`, `--themes` +- Global flags: `--mode`, `--theme`, `--nocolor`, `--themes` diff --git a/docs/plans/jobs/2026-02-04-phase-3-tests.md b/docs/plans/jobs/2026-02-04-phase-3-tests.md index c877aa4..9d80ba4 100644 --- a/docs/plans/jobs/2026-02-04-phase-3-tests.md +++ b/docs/plans/jobs/2026-02-04-phase-3-tests.md @@ -12,3 +12,4 @@ agent: codex - Added CSV output validation for the modified_time field. - Normalized test environment with isolated `HOME` and resolved `CODEX_HOME` symlinks to avoid host config leakage. - Fixed lint issues: explicit temp patch cleanup handling, checked file close errors, and removed ineffectual task initialization. +- Updated README and man pages for codex mode usage and apply command documentation. diff --git a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md index 7a4444d..b67c539 100644 --- a/docs/plans/plan-2026-02-04-mode-classic-and-codex.md +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -2,7 +2,7 @@ title: "Mode flag: classic and codex" date: 2026-02-04 modified-date: 2026-02-04 -status: active +status: completed agent: codex --- @@ -71,17 +71,17 @@ Add a new global `--mode` (`classic` default, `codex` optional) to support Codex - [x] Add tests for codex cleanup scope restriction + confirmation flow. ### Phase 4: README / CLI Docs Update -- [ ] Update `README.md`: - - [ ] Document `--mode`, `GWTT_MODE`, and config `mode`. - - [ ] Add codex-mode usage examples for `list/status/apply/cleanup`. - - [ ] Update `## Notes` “Global flags” list to include `--mode`. - - [ ] Document `modified_time` in `status` outputs (and the fixed date format). -- [ ] If applicable, update any man/help text sources under `man/` to reflect new commands/flags. +- [x] Update `README.md`: + - [x] Document `--mode`, `GWTT_MODE`, and config `mode`. + - [x] Add codex-mode usage examples for `list/status/apply/cleanup`. + - [x] Update `## Notes` “Global flags” list to include `--mode`. + - [x] Document `modified_time` in `status` outputs (and the fixed date format). +- [x] If applicable, update any man/help text sources under `man/` to reflect new commands/flags. ### Phase 5: Verify Doc Statuses -- [ ] Ensure this plan’s `status` matches the actual phase progress (`active` -> `completed` when done). -- [ ] Update the research doc’s `status` to `completed` once decisions are implemented and verified. -- [ ] Ensure any schema/doc updates have consistent status and dates (`modified-date` as needed). +- [x] Ensure this plan’s `status` matches the actual phase progress (`active` -> `completed` when done). +- [x] Update the research doc’s `status` to `completed` once decisions are implemented and verified. +- [x] Ensure any schema/doc updates have consistent status and dates (`modified-date` as needed). ## Acceptance Criteria - Default behavior (no `--mode`, no `GWTT_MODE`, no config) remains unchanged. diff --git a/docs/research-2026-02-04-mode-classic-vs-codex.md b/docs/research-2026-02-04-mode-classic-vs-codex.md index a790e28..5865337 100644 --- a/docs/research-2026-02-04-mode-classic-vs-codex.md +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -2,7 +2,7 @@ title: "Mode Flag: classic vs codex" date: 2026-02-04 modified-date: 2026-02-04 -status: in-progress +status: completed agent: codex --- diff --git a/man/man1/git-worktree-tasks.1 b/man/man1/git-worktree-tasks.1 index bbbf1e6..0a8b4ef 100644 --- a/man/man1/git-worktree-tasks.1 +++ b/man/man1/git-worktree-tasks.1 @@ -1,5 +1,5 @@ .nh -.TH "GIT-WORKTREE-TASKS" "1" "Jan 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" +.TH "GIT-WORKTREE-TASKS" "1" "Feb 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" .SH NAME git-worktree-tasks - Task-based git worktree helper @@ -17,6 +17,10 @@ Create, manage, and clean up git worktrees based on task names. \fB-h\fP, \fB--help\fP[=false] help for git-worktree-tasks +.PP +\fB--mode\fP="classic" + execution mode: classic or codex + .PP \fB--nocolor\fP[=false] disable color output @@ -31,4 +35,4 @@ Create, manage, and clean up git worktrees based on task names. .SH SEE ALSO -\fBgit-worktree-tasks-cleanup(1)\fP, \fBgit-worktree-tasks-create(1)\fP, \fBgit-worktree-tasks-finish(1)\fP, \fBgit-worktree-tasks-list(1)\fP, \fBgit-worktree-tasks-status(1)\fP +\fBgit-worktree-tasks-apply(1)\fP, \fBgit-worktree-tasks-cleanup(1)\fP, \fBgit-worktree-tasks-create(1)\fP, \fBgit-worktree-tasks-finish(1)\fP, \fBgit-worktree-tasks-list(1)\fP, \fBgit-worktree-tasks-status(1)\fP diff --git a/man/man1/git-worktree-tasks_apply.1 b/man/man1/git-worktree-tasks_apply.1 new file mode 100644 index 0000000..0c1b98f --- /dev/null +++ b/man/man1/git-worktree-tasks_apply.1 @@ -0,0 +1,47 @@ +.nh +.TH "GIT-WORKTREE-TASKS" "1" "Feb 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" + +.SH NAME +git-worktree-tasks-apply - Apply changes between a Codex worktree and the local checkout + + +.SH SYNOPSIS +\fBgit-worktree-tasks apply [flags]\fP + + +.SH DESCRIPTION +Apply changes between a Codex worktree and the local checkout + + +.SH OPTIONS +\fB--dry-run\fP[=false] + show git commands without executing + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for apply + +.PP +\fB--yes\fP[=false] + skip confirmation prompts + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP +\fB--nocolor\fP[=false] + disable color output + +.PP +\fB--theme\fP="default" + color theme: default, dracula, gruvbox, nord, solarized + +.PP +\fB--themes\fP[=false] + print available themes and exit + + +.SH SEE ALSO +\fBgit-worktree-tasks(1)\fP diff --git a/man/man1/git-worktree-tasks_cleanup.1 b/man/man1/git-worktree-tasks_cleanup.1 index 214b6e2..a4a77dd 100644 --- a/man/man1/git-worktree-tasks_cleanup.1 +++ b/man/man1/git-worktree-tasks_cleanup.1 @@ -1,5 +1,5 @@ .nh -.TH "GIT-WORKTREE-TASKS" "1" "Jan 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" +.TH "GIT-WORKTREE-TASKS" "1" "Feb 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" .SH NAME git-worktree-tasks-cleanup - Remove a task worktree and/or branch @@ -43,6 +43,10 @@ Remove a task worktree and/or branch .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/git-worktree-tasks_create.1 b/man/man1/git-worktree-tasks_create.1 index bd28d73..ac28d10 100644 --- a/man/man1/git-worktree-tasks_create.1 +++ b/man/man1/git-worktree-tasks_create.1 @@ -1,5 +1,5 @@ .nh -.TH "GIT-WORKTREE-TASKS" "1" "Jan 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" +.TH "GIT-WORKTREE-TASKS" "1" "Feb 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" .SH NAME git-worktree-tasks-create - Create a worktree and branch for a task @@ -43,6 +43,10 @@ Create a worktree and branch for a task .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/git-worktree-tasks_finish.1 b/man/man1/git-worktree-tasks_finish.1 index 11eea61..c30f210 100644 --- a/man/man1/git-worktree-tasks_finish.1 +++ b/man/man1/git-worktree-tasks_finish.1 @@ -1,5 +1,5 @@ .nh -.TH "GIT-WORKTREE-TASKS" "1" "Jan 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" +.TH "GIT-WORKTREE-TASKS" "1" "Feb 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" .SH NAME git-worktree-tasks-finish - Merge a task branch into a target branch @@ -59,6 +59,10 @@ Merge a task branch into a target branch .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/git-worktree-tasks_list.1 b/man/man1/git-worktree-tasks_list.1 index 73af1cd..fd36d22 100644 --- a/man/man1/git-worktree-tasks_list.1 +++ b/man/man1/git-worktree-tasks_list.1 @@ -1,5 +1,5 @@ .nh -.TH "GIT-WORKTREE-TASKS" "1" "Jan 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" +.TH "GIT-WORKTREE-TASKS" "1" "Feb 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" .SH NAME git-worktree-tasks-list - List task worktrees @@ -47,6 +47,10 @@ List task worktrees .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/git-worktree-tasks_status.1 b/man/man1/git-worktree-tasks_status.1 index 75acf68..1c1f7c2 100644 --- a/man/man1/git-worktree-tasks_status.1 +++ b/man/man1/git-worktree-tasks_status.1 @@ -1,5 +1,5 @@ .nh -.TH "GIT-WORKTREE-TASKS" "1" "Jan 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" +.TH "GIT-WORKTREE-TASKS" "1" "Feb 2026" "git-worktree-tasks" "Git Worktree Tasks Manual" .SH NAME git-worktree-tasks-status - Show detailed worktree status @@ -51,6 +51,10 @@ Show detailed worktree status .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/gwtt.1 b/man/man1/gwtt.1 index f907403..048f778 100644 --- a/man/man1/gwtt.1 +++ b/man/man1/gwtt.1 @@ -1,5 +1,5 @@ .nh -.TH "GWTT" "1" "Jan 2026" "gwtt" "Git Worktree Tasks Manual" +.TH "GWTT" "1" "Feb 2026" "gwtt" "Git Worktree Tasks Manual" .SH NAME gwtt - Task-based git worktree helper @@ -17,6 +17,10 @@ Create, manage, and clean up git worktrees based on task names. \fB-h\fP, \fB--help\fP[=false] help for gwtt +.PP +\fB--mode\fP="classic" + execution mode: classic or codex + .PP \fB--nocolor\fP[=false] disable color output @@ -31,4 +35,4 @@ Create, manage, and clean up git worktrees based on task names. .SH SEE ALSO -\fBgwtt-cleanup(1)\fP, \fBgwtt-create(1)\fP, \fBgwtt-finish(1)\fP, \fBgwtt-list(1)\fP, \fBgwtt-status(1)\fP +\fBgwtt-apply(1)\fP, \fBgwtt-cleanup(1)\fP, \fBgwtt-create(1)\fP, \fBgwtt-finish(1)\fP, \fBgwtt-list(1)\fP, \fBgwtt-status(1)\fP diff --git a/man/man1/gwtt_apply.1 b/man/man1/gwtt_apply.1 new file mode 100644 index 0000000..f3d505f --- /dev/null +++ b/man/man1/gwtt_apply.1 @@ -0,0 +1,47 @@ +.nh +.TH "GWTT" "1" "Feb 2026" "gwtt" "Git Worktree Tasks Manual" + +.SH NAME +gwtt-apply - Apply changes between a Codex worktree and the local checkout + + +.SH SYNOPSIS +\fBgwtt apply [flags]\fP + + +.SH DESCRIPTION +Apply changes between a Codex worktree and the local checkout + + +.SH OPTIONS +\fB--dry-run\fP[=false] + show git commands without executing + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for apply + +.PP +\fB--yes\fP[=false] + skip confirmation prompts + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP +\fB--nocolor\fP[=false] + disable color output + +.PP +\fB--theme\fP="default" + color theme: default, dracula, gruvbox, nord, solarized + +.PP +\fB--themes\fP[=false] + print available themes and exit + + +.SH SEE ALSO +\fBgwtt(1)\fP diff --git a/man/man1/gwtt_cleanup.1 b/man/man1/gwtt_cleanup.1 index 7ea007d..ec26829 100644 --- a/man/man1/gwtt_cleanup.1 +++ b/man/man1/gwtt_cleanup.1 @@ -1,5 +1,5 @@ .nh -.TH "GWTT" "1" "Jan 2026" "gwtt" "Git Worktree Tasks Manual" +.TH "GWTT" "1" "Feb 2026" "gwtt" "Git Worktree Tasks Manual" .SH NAME gwtt-cleanup - Remove a task worktree and/or branch @@ -43,6 +43,10 @@ Remove a task worktree and/or branch .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/gwtt_create.1 b/man/man1/gwtt_create.1 index d289f16..2142d44 100644 --- a/man/man1/gwtt_create.1 +++ b/man/man1/gwtt_create.1 @@ -1,5 +1,5 @@ .nh -.TH "GWTT" "1" "Jan 2026" "gwtt" "Git Worktree Tasks Manual" +.TH "GWTT" "1" "Feb 2026" "gwtt" "Git Worktree Tasks Manual" .SH NAME gwtt-create - Create a worktree and branch for a task @@ -43,6 +43,10 @@ Create a worktree and branch for a task .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/gwtt_finish.1 b/man/man1/gwtt_finish.1 index 6f87091..92c38f8 100644 --- a/man/man1/gwtt_finish.1 +++ b/man/man1/gwtt_finish.1 @@ -1,5 +1,5 @@ .nh -.TH "GWTT" "1" "Jan 2026" "gwtt" "Git Worktree Tasks Manual" +.TH "GWTT" "1" "Feb 2026" "gwtt" "Git Worktree Tasks Manual" .SH NAME gwtt-finish - Merge a task branch into a target branch @@ -59,6 +59,10 @@ Merge a task branch into a target branch .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/gwtt_list.1 b/man/man1/gwtt_list.1 index d673ede..27e49b1 100644 --- a/man/man1/gwtt_list.1 +++ b/man/man1/gwtt_list.1 @@ -1,5 +1,5 @@ .nh -.TH "GWTT" "1" "Jan 2026" "gwtt" "Git Worktree Tasks Manual" +.TH "GWTT" "1" "Feb 2026" "gwtt" "Git Worktree Tasks Manual" .SH NAME gwtt-list - List task worktrees @@ -47,6 +47,10 @@ List task worktrees .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output diff --git a/man/man1/gwtt_status.1 b/man/man1/gwtt_status.1 index 190ba97..244fbe5 100644 --- a/man/man1/gwtt_status.1 +++ b/man/man1/gwtt_status.1 @@ -1,5 +1,5 @@ .nh -.TH "GWTT" "1" "Jan 2026" "gwtt" "Git Worktree Tasks Manual" +.TH "GWTT" "1" "Feb 2026" "gwtt" "Git Worktree Tasks Manual" .SH NAME gwtt-status - Show detailed worktree status @@ -51,6 +51,10 @@ Show detailed worktree status .SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--mode\fP="classic" + execution mode: classic or codex + +.PP \fB--nocolor\fP[=false] disable color output From d0fdb188464ef5a999a0ff15b46a9252fb346531 Mon Sep 17 00:00:00 2001 From: nakolus Date: Thu, 5 Feb 2026 01:36:48 +0800 Subject: [PATCH 17/19] chore: run Git apply --check before applying patch --- cli/apply.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/apply.go b/cli/apply.go index 0654c07..f5681f1 100644 --- a/cli/apply.go +++ b/cli/apply.go @@ -319,6 +319,9 @@ func overwriteWorktreeChanges(ctx context.Context, cmd *cobra.Command, runner gi }() if patch != "" { + if err := runGit(ctx, cmd, dryRun, runner, "-C", worktreePath, "apply", "--check", patchFile); err != nil { + return &applyConflictError{reason: "apply patch check failed", err: err} + } if err := runGit(ctx, cmd, dryRun, runner, "-C", worktreePath, "apply", patchFile); err != nil { return err } From ce06102efd0fdb24d0a742260f7b57ab32e54c4d Mon Sep 17 00:00:00 2001 From: nakolus Date: Thu, 5 Feb 2026 01:41:11 +0800 Subject: [PATCH 18/19] chore: handle symlinks in copyFile and validate files --- cli/apply.go | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/cli/apply.go b/cli/apply.go index f5681f1..0bd6e9e 100644 --- a/cli/apply.go +++ b/cli/apply.go @@ -391,12 +391,40 @@ func writeTempPatch(contents string) (string, error) { } func copyFile(srcRoot, dstRoot, rel string, dryRun bool, out io.Writer) (err error) { + srcPath := filepath.Join(srcRoot, rel) + dstPath := filepath.Join(dstRoot, rel) + + info, err := os.Lstat(srcPath) + if err != nil { + return err + } + + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(srcPath) + if err != nil { + return err + } + if dryRun { + _, err := fmt.Fprintf(out, "symlink %s -> %s (%s)\n", srcPath, dstPath, target) + return err + } + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + if err := os.Remove(dstPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return os.Symlink(target, dstPath) + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported file type for copy: %s", srcPath) + } + if dryRun { - _, err := fmt.Fprintf(out, "copy %s -> %s\n", filepath.Join(srcRoot, rel), filepath.Join(dstRoot, rel)) + _, err := fmt.Fprintf(out, "copy %s -> %s\n", srcPath, dstPath) return err } - srcPath := filepath.Join(srcRoot, rel) - dstPath := filepath.Join(dstRoot, rel) if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { return err @@ -411,11 +439,6 @@ func copyFile(srcRoot, dstRoot, rel string, dryRun bool, out io.Writer) (err err } }() - info, err := in.Stat() - if err != nil { - return err - } - outFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) if err != nil { return err From bab726cae44cf37f1cf82bd7bad8ee6db290fb1c Mon Sep 17 00:00:00 2001 From: nakolus Date: Thu, 5 Feb 2026 01:56:44 +0800 Subject: [PATCH 19/19] Bump CLI Version to 0.1.1 --- cli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/root.go b/cli/root.go index 8e5f946..75bdf72 100644 --- a/cli/root.go +++ b/cli/root.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var Version = "0.1.1-alpha.2" +var Version = "0.1.1" var ( errCanceled = errors.New("git worktree task process canceled")