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/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/cli/apply.go b/cli/apply.go new file mode 100644 index 0000000..0bd6e9e --- /dev/null +++ b/cli/apply.go @@ -0,0 +1,466 @@ +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 applyOptions struct { + yes bool + dryRun bool +} + +type applyConflictError struct { + reason string + err error +} + +func (e *applyConflictError) Error() string { + if e.err == nil { + return e.reason + } + return fmt.Sprintf("%s: %v", e.reason, e.err) +} + +func (e *applyConflictError) Unwrap() error { return e.err } + +func newApplyCommand() *cobra.Command { + opts := &applyOptions{} + cmd := &cobra.Command{ + 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("apply 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 := 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("apply 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 overwriteWorktreeChanges(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) + } + + 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 { + 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 overwriteWorktreeChanges(ctx, cmd, runner, repoRoot, wtPath, opts.dryRun) + } + return err + } + if _, err := fmt.Fprintln(cmd.OutOrStdout(), ui.SuccessStyle.Render("apply 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 + } + id, _, ok := codexWorktreeInfo(codexWorktreesRoot, wtAbs) + if !ok || id != opaqueID { + continue + } + return wtAbs, true, nil + } + return "", false, nil +} + +func detectApplyConflicts(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 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 + } + + patchFile, err := writeTempPatch(patch) + if err != nil { + return err + } + 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 { + 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) + } + } + + 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 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 + } + 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 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", "--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 + } + } + + 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-apply-*.patch") + if err != nil { + return "", err + } + 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) (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", srcPath, dstPath) + return err + } + + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + in, err := os.Open(srcPath) + if err != nil { + return err + } + defer func() { + if closeErr := in.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() + + outFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) + if err != nil { + return err + } + 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/cleanup.go b/cli/cleanup.go index 51a67b1..aff6b1b 100644 --- a/cli/cleanup.go +++ b/cli/cleanup.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/pi2pie/git-worktree-tasks/internal/git" "github.com/pi2pie/git-worktree-tasks/internal/worktree" @@ -28,7 +29,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 +69,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 +104,60 @@ 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 + } + opaqueID, _, ok := codexWorktreeInfo(codexWorktrees, wtAbs) + if !ok || opaqueID != query { + continue + } + resolvedPath = wtAbs + 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 +203,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..dbb5ac3 --- /dev/null +++ b/cli/codex.go @@ -0,0 +1,64 @@ +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") +} + +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/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/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..bc15929 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 apply 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/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 c0afeaf..e5c0c52 100644 --- a/cli/list.go +++ b/cli/list.go @@ -4,6 +4,8 @@ import ( "encoding/csv" "encoding/json" "fmt" + "os" + "path/filepath" "strconv" "strings" @@ -41,7 +43,25 @@ 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 + var rawBase 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) + } else { + if home, err := codexHomeDir(); err == nil { + codexHome = home + codexWorktrees = codexWorktreesRoot(codexHome) + } + } if !cmd.Flags().Changed("output") { opts.output = cfg.List.Output } @@ -58,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 @@ -71,9 +96,16 @@ 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") + } + } else { + query, err = normalizeTaskQuery(args[0]) + if err != nil { + return err + } } } if opts.output == "raw" && query == "" && opts.branch == "" { @@ -96,27 +128,84 @@ 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) + var task string + var codexRel string + var wtAbs string + var err error + if mode == modeCodex { + wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + opaqueID, rel, ok := codexWorktreeInfo(codexWorktrees, wtAbs) + if !ok { + continue + } + 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 = "-" } 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 mode == modeCodex && opts.output == "raw" && field == "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) + } + } + rawPathDone: + if query != "" { + if !matchesTask(row.Task, query, opts.strict) { + continue + } } if opts.branch != "" && row.Branch != opts.branch { continue } rows = append(rows, row) + if query != "" && !opts.strict { + break + } } - 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/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 acd0f78..75bdf72 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" var ( errCanceled = errors.New("git worktree task process canceled") @@ -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(), + newApplyCommand(), newTUICommand(), ) diff --git a/cli/status.go b/cli/status.go index 2285d15..75f7895 100644 --- a/cli/status.go +++ b/cli/status.go @@ -5,8 +5,10 @@ import ( "encoding/json" "errors" "fmt" + "os" "strconv" "strings" + "time" "github.com/charmbracelet/lipgloss" "github.com/pi2pie/git-worktree-tasks/internal/git" @@ -26,15 +28,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 +49,24 @@ 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) + } else { + if home, err := codexHomeDir(); err == nil { + codexHome = home + codexWorktrees = codexWorktreesRoot(codexHome) + } + } if !cmd.Flags().Changed("output") { opts.output = cfg.Status.Output } @@ -73,17 +93,34 @@ 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") + } + } 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 + } + if mode != modeCodex { + opts.strict = true } - opts.strict = true } target := opts.target @@ -108,13 +145,44 @@ 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) + var task string + var wtAbs string + if mode == modeCodex { + var err error + wtAbs, err = worktree.NormalizePath(repoRoot, wt.Path) + if err != nil { + return err + } + opaqueID, _, ok := codexWorktreeInfo(codexWorktrees, wtAbs) + if !ok { + continue + } + task = opaqueID + if branch == "" { + branch = "detached" + } + if query != "" && !matchesTask(task, query, opts.strict) { + 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 query != "" && !matchesTask(task, query, opts.strict) { + continue + } + } if task == "" { task = "-" } - if query != "" && !matchesTask(task, query, opts.strict) { - continue - } if opts.branch != "" && branch != opts.branch { continue } @@ -123,21 +191,41 @@ func newStatusCommand() *cobra.Command { if err != nil { return err } + if wtAbs == "" { + var err error + 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 query != "" && !opts.strict { + break + } } - if len(rows) == 0 { + if mode != modeCodex && len(rows) == 0 { fallbackBranch := opts.branch if fallbackBranch == "" { fallbackBranch = query @@ -155,16 +243,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 +290,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 +319,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 +342,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 +351,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/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/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/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. + 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. + 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/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..9d80ba4 --- /dev/null +++ b/docs/plans/jobs/2026-02-04-phase-3-tests.md @@ -0,0 +1,15 @@ +--- +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. +- Updated README and man pages for codex mode usage and apply command documentation. 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) 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..b67c539 --- /dev/null +++ b/docs/plans/plan-2026-02-04-mode-classic-and-codex.md @@ -0,0 +1,99 @@ +--- +title: "Mode flag: classic and codex" +date: 2026-02-04 +modified-date: 2026-02-04 +status: completed +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 `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). + +## 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 +- [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] `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 +- [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 **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 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. 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 +- [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 +- [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 +- [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. +- `--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. +- `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 new file mode 100644 index 0000000..5865337 --- /dev/null +++ b/docs/research-2026-02-04-mode-classic-vs-codex.md @@ -0,0 +1,163 @@ +--- +title: "Mode Flag: classic vs codex" +date: 2026-02-04 +modified-date: 2026-02-04 +status: completed +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 (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. +- **“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. + +### 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 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 **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 `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. +- **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`). + - 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 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. + +### 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, 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 `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: + - 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). +- 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). + +## 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 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) +- 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. + +### `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 transferred. + +### `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: 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” (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/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/ +- Git worktree manual: https://git-scm.com/docs/git-worktree + +## Related Plans +- `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..8813c47 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"`) @@ -75,10 +87,13 @@ 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 ```toml +mode = "classic" + [theme] name = "nord" 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 } 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