diff --git a/.claude/parallel-agents-starter.md b/.claude/parallel-agents-starter.md deleted file mode 100644 index c3282c9..0000000 --- a/.claude/parallel-agents-starter.md +++ /dev/null @@ -1,178 +0,0 @@ -# Parallel Agents Feature - Session Starter - -## Quick Start (Single Agent - Sequential) - -``` -Continue jjtask parallel agents implementation. - -Read the handoff first: - jj task show-desc qq - -Task DAG starting point: - jj log -r 'descendants(mt)' - -Start with these tasks in order: -1. zr - Schema parser (internal/parallel/schema.go) -2. zt - Workspace management (internal/workspace/workspace.go) -3. pp - parallel-start command (cmd/jjtask/cmd/parallel_start.go) -4. qk - Mode 1 shared filesystem implementation - -Mark tasks done as you complete them: jj task flag done -``` - ---- - -## Parallel Start (2 Agents - Shared Mode) - -### Setup (run once before launching agents) -```bash -cd /Users/alex/Projects/jjtask -jj edit mt -jj task flag @ wip -``` - -### Agent A Prompt -``` -You are agent-a implementing jjtask parallel agents feature. - -Repo: /Users/alex/Projects/jjtask -Mode: shared (you share @ with agent-b) - -YOUR ASSIGNMENT: internal/parallel/** -AVOID: internal/workspace/** (agent-b) - -FIRST: Read the handoff - jj task show-desc qq - -YOUR TASK: zr (Format: Task description schema) - jj task show-desc zr - -Create internal/parallel/schema.go with: -- ParallelSession struct -- Agent struct -- ParseParallelSession(description string) function -- FormatParallelSession(session) function - -When done: jj task flag zr done -Then continue with: lo (agent-context command) -``` - -### Agent B Prompt -``` -You are agent-b implementing jjtask parallel agents feature. - -Repo: /Users/alex/Projects/jjtask -Mode: shared (you share @ with agent-a) - -YOUR ASSIGNMENT: internal/workspace/** -AVOID: internal/parallel/** (agent-a) - -FIRST: Read the handoff - jj task show-desc qq - -YOUR TASK: zt (Setup: .jjtask-workspaces management) - jj task show-desc zt - -Create internal/workspace/workspace.go with: -- EnsureWorkspacesDir() function -- EnsureIgnored() function (adds to .git/info/exclude) -- CreateWorkspace(name, revision) function -- CleanupWorkspaces() function - -When done: jj task flag zt done -Then continue with: pp (parallel-start command) with agent-a -``` - ---- - -## Parallel Start (2 Agents - Workspace Mode) - -### Setup (run once) -```bash -cd /Users/alex/Projects/jjtask -echo '.jjtask-workspaces/' >> .git/info/exclude -mkdir -p .jjtask-workspaces - -# Create workspaces for independent work -jj workspace add .jjtask-workspaces/agent-a --revision zr --name agent-a -jj workspace add .jjtask-workspaces/agent-b --revision zt --name agent-b -``` - -### Agent A Prompt -``` -You are agent-a implementing jjtask parallel agents feature. - -Working directory: /Users/alex/Projects/jjtask/.jjtask-workspaces/agent-a -Mode: workspace (isolated) - -FIRST: Read the handoff - jj task show-desc qq - -YOUR TASK: zr (Format: Task description schema) - jj task show-desc @- - -Create internal/parallel/schema.go with: -- ParallelSession struct -- Agent struct -- ParseParallelSession(description string) function -- FormatParallelSession(session) function - -When done: jj task flag @- done -``` - -### Agent B Prompt -``` -You are agent-b implementing jjtask parallel agents feature. - -Working directory: /Users/alex/Projects/jjtask/.jjtask-workspaces/agent-b -Mode: workspace (isolated) - -FIRST: Read the handoff - jj task show-desc qq - -YOUR TASK: zt (Setup: .jjtask-workspaces management) - jj task show-desc @- - -Create internal/workspace/workspace.go with: -- EnsureWorkspacesDir() function -- EnsureIgnored() function -- CreateWorkspace(name, revision) function -- CleanupWorkspaces() function - -When done: jj task flag @- done -``` - -### Cleanup (after both agents done) -```bash -jj workspace forget agent-a agent-b -rm -rf .jjtask-workspaces/ -# Merge work -jj new zr zt -m "Parallel agents foundation" -``` - ---- - -## Task Reference - -| ID | Task | Files | -|----|------|-------| -| qq | Handoff doc | (read this first) | -| zr | Schema parser | internal/parallel/schema.go | -| zt | Workspace mgmt | internal/workspace/workspace.go | -| pp | parallel-start cmd | cmd/jjtask/cmd/parallel_start.go | -| qk | Mode 1 (shared) | cmd/jjtask/cmd/parallel_start.go | -| qum | Mode 2 (workspace) | cmd/jjtask/cmd/parallel_start.go | -| lo | agent-context cmd | cmd/jjtask/cmd/agent_context.go | -| lt | parallel-status cmd | cmd/jjtask/cmd/parallel_status.go | -| nx | parallel-stop cmd | cmd/jjtask/cmd/parallel_stop.go | -| rn | prime enhancement | cmd/jjtask/cmd/prime.go | - -## Useful Commands - -```bash -jj task show-desc # Read task spec -jj task flag wip # Start working -jj task flag done # Mark complete -jj log -r 'descendants(mt)' # See DAG -jj task find # See pending tasks -``` diff --git a/.dev-backup/.claude-plugin/plugin.json b/.dev-backup/.claude-plugin/plugin.json new file mode 100644 index 0000000..27d6b36 --- /dev/null +++ b/.dev-backup/.claude-plugin/plugin.json @@ -0,0 +1,48 @@ +{ + "name": "jjtask", + "description": "Structured task management using JJ (Jujutsu). Uses empty revisions as TODO markers with [task:*] flags, forming a DAG of plannable and executable tasks.", + "version": "0.1.1", + "author": { + "name": "Alexander Ryzhikov", + "url": "https://github.com/coobaha" + }, + "repository": "https://github.com/coobaha/jjtask", + "license": "MIT", + "homepage": "https://github.com/coobaha/jjtask", + "keywords": [ + "task-management", + "jujutsu", + "jj", + "version-control", + "ai-workflow", + "todo", + "dag", + "parallel-agents" + ], + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/jjtask prime", + "timeout": 10 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/jjtask prime", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.dev-backup/bin/jj b/.dev-backup/bin/jj new file mode 100755 index 0000000..bddcb73 --- /dev/null +++ b/.dev-backup/bin/jj @@ -0,0 +1,125 @@ +#!/bin/bash +# jj wrapper with agent guardrails +# Blocks bad patterns, shows hints for jjtask-* commands + +# Enable colors if stdout is a TTY (overrides agent.toml's color=never) +if [[ -t 1 ]]; then + JJ_TTY_ARGS=(--color=always) +else + JJ_TTY_ARGS=() +fi + +# Find real jj binary (skip this wrapper and symlinks to it) +JJ="" +SELF_REAL="$(realpath "$0" 2>/dev/null || echo "$0")" +for p in $(echo "$PATH" | tr ':' '\n'); do + if [[ -x "$p/jj" ]]; then + candidate_real="$(realpath "$p/jj" 2>/dev/null || echo "$p/jj")" + if [[ "$candidate_real" != "$SELF_REAL" ]]; then + JJ="$p/jj" + break + fi + fi +done +if [[ -z "$JJ" ]]; then + echo "Error: Could not find jj binary in PATH" >&2 + exit 1 +fi + +SESSION_ID="${X_CLAUDE_SESSION_ID:-$$}" + +hint_shown() { + [[ -f "/tmp/.jj_hint_${SESSION_ID}_$1" ]] +} + +mark_hint() { + touch "/tmp/.jj_hint_${SESSION_ID}_$1" +} + +show_hint() { + [[ -n "${JJ_NO_HINTS:-}" ]] && return + if ! hint_shown "$1"; then + mark_hint "$1" + shift + echo "$@" >&2 + fi +} + +jj_exec() { + exec "$JJ" ${JJ_TTY_ARGS[@]+"${JJ_TTY_ARGS[@]}"} "$@" +} + +if [[ $# -eq 0 ]]; then + [[ -f .jj-workspaces.yaml ]] && show_hint logall "AGENT HINT: For multi-repo: jjtask all log" + jj_exec +fi + +case "$1" in + log) + for arg in "$@"; do + case "$arg" in + *glob:*\[*|*glob:*task*|*description*glob*task*) + echo "BLOCKED (exit 1): Bad glob - brackets [] are character classes" >&2 + echo " Use: description(substring:\"[task:\")" >&2 + echo " Or alias: tasks(), tasks_pending()" >&2 + exit 1 + ;; + *\[task:*) + echo "BLOCKED (exit 1): Raw [task: in revset won't work" >&2 + echo " Use: description(substring:\"[task:\")" >&2 + echo " Or revset aliases:" >&2 + echo " tasks() tasks_pending() tasks_ready()" >&2 + echo " tasks_todo() tasks_wip() tasks_done()" >&2 + echo " tasks_blocked() tasks_draft() tasks_review()" >&2 + echo " tasks_stale() tasks_next()" >&2 + exit 1 + ;; + esac + done + [[ -f .jj-workspaces.yaml ]] && show_hint logall "AGENT HINT: For multi-repo: jjtask all log" + has_limit=0 + for arg in "$@"; do + case "$arg" in + --limit=*|-l|--limit|-n|-n[0-9]*) + has_limit=1 + ;; + esac + done + if [[ $has_limit -eq 0 ]]; then + shift + jj_exec log --limit=15 "$@" + fi + ;; + show) + show_hint show "AGENT HINT: For description only: jjtask show-desc [REV]" + ;; + describe|desc) + show_hint desc "AGENT HINT: Description patterns:" \ + $'\n'" jjtask flag REV FLAG # status only" \ + $'\n'" jjtask desc-transform REV SED # partial edit via sed" \ + $'\n'" jj desc -r REV -m \"\$(jjtask show-desc REV)...\" # append to desc" + ;; + new) + if [[ -z "${JJ_ALLOW_TASK:-}" ]]; then + for arg in "$@"; do + case "$arg" in + *task:*) + echo "BLOCKED (exit 1): Use jjtask create for task revisions:" >&2 + echo " jjtask create TITLE # parent=@" >&2 + echo " jjtask create PARENT TITLE DESC # explicit parent" >&2 + echo " jjtask parallel PARENT T1 T2 # multiple siblings" >&2 + exit 1 + ;; + esac + done + fi + ;; + rebase) + show_hint rebase "AGENT HINT: For hoisting pending tasks to @: jjtask hoist" + ;; + edit) + show_hint edit "AGENT HINT: For task transitions: jjtask next --mark-as STATUS REV" + ;; +esac + +jj_exec "$@" diff --git a/.dev-backup/bin/jjtask b/.dev-backup/bin/jjtask new file mode 100755 index 0000000..eac0a80 --- /dev/null +++ b/.dev-backup/bin/jjtask @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# jjtask - dispatcher for jj task subcommand +# Delegates to Go binary, downloads if missing + +set -euo pipefail + +JJTASK_SOURCE="${BASH_SOURCE[0]}" +while [[ -L "$JJTASK_SOURCE" ]]; do + JJTASK_SOURCE="$(readlink "$JJTASK_SOURCE")" +done +JJTASK_BIN="$(cd "$(dirname "$JJTASK_SOURCE")" && pwd)" +JJTASK_GO="$JJTASK_BIN/jjtask-go" + +download_binary() { + local os arch asset + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + case "$arch" in + x86_64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "jjtask: unsupported architecture: $arch" >&2; return 1 ;; + esac + + asset="jjtask-${os}-${arch}.tar.gz" + local tmp + tmp="$(mktemp -d)" + trap "rm -rf '$tmp'" EXIT + + # Try gh CLI first (works with private repos) + if command -v gh &>/dev/null; then + echo "jjtask: downloading via gh..." >&2 + if gh release download --repo coobaha/jjtask --pattern "$asset" --dir "$tmp" 2>/dev/null; then + tar -xzf "$tmp/$asset" -C "$tmp" + mv "$tmp/jjtask-go" "$JJTASK_GO" + chmod +x "$JJTASK_GO" + echo "jjtask: installed to $JJTASK_GO" >&2 + return 0 + fi + fi + + # Fall back to curl (public repos only) + local url="https://github.com/coobaha/jjtask/releases/latest/download/$asset" + echo "jjtask: downloading from $url..." >&2 + if curl -fsSL "$url" -o "$tmp/$asset"; then + tar -xzf "$tmp/$asset" -C "$tmp" + mv "$tmp/jjtask-go" "$JJTASK_GO" + chmod +x "$JJTASK_GO" + echo "jjtask: installed to $JJTASK_GO" >&2 + return 0 + fi + + return 1 +} + +# Download if missing +if [[ ! -x "$JJTASK_GO" ]]; then + if ! download_binary; then + echo "jjtask: binary not found and download failed." >&2 + echo "jjtask: install Go and run: go build -o bin/jjtask-go ./cmd/jjtask" >&2 + exit 1 + fi +fi + +exec "$JJTASK_GO" "$@" diff --git a/claude-plugin/bin/jjtask-go b/.dev-backup/bin/jjtask-go similarity index 100% rename from claude-plugin/bin/jjtask-go rename to .dev-backup/bin/jjtask-go diff --git a/claude-plugin/commands/agent-context.md b/.dev-backup/commands/agent-context.md similarity index 100% rename from claude-plugin/commands/agent-context.md rename to .dev-backup/commands/agent-context.md diff --git a/.dev-backup/commands/batch-desc.md b/.dev-backup/commands/batch-desc.md new file mode 100644 index 0000000..a876dc0 --- /dev/null +++ b/.dev-backup/commands/batch-desc.md @@ -0,0 +1,16 @@ +--- +description: Apply sed transformations to multiple revision descriptions +argument-hint: +allowed-tools: + - Bash +--- + + +Transform descriptions of multiple revisions matching a revset. + +Example: `jjtask batch-desc 's/old/new/' 'tasks_pending()'` + + + +Run: `jjtask batch-desc $ARGUMENTS` + diff --git a/.dev-backup/commands/checkpoint.md b/.dev-backup/commands/checkpoint.md new file mode 100644 index 0000000..e4e743e --- /dev/null +++ b/.dev-backup/commands/checkpoint.md @@ -0,0 +1,16 @@ +--- +description: Create a checkpoint commit +argument-hint: [name] +allowed-tools: + - Bash +--- + + +Record the current operation ID before risky operations. + +Restore later with: `jj op restore ` + + + +Run: `jjtask checkpoint $ARGUMENTS` + diff --git a/.dev-backup/commands/create.md b/.dev-backup/commands/create.md new file mode 100644 index 0000000..c118320 --- /dev/null +++ b/.dev-backup/commands/create.md @@ -0,0 +1,43 @@ +--- +description: Create a new todo task revision +argument-hint: [parent] [description] +allowed-tools: + - Skill(jjtask) + - Read + - Bash +--- + +<objective> +Create a new jj revision marked as a todo task. + +Parent defaults to @ if only title provided. + +Part of `/jjtask` - run that skill for full workflow context. +</objective> + +<context> +Existing tasks: +!`jjtask find 2>/dev/null || jj log -r 'tasks_pending()' -T task_log --limit 20` + +Recent commits (potential parents): +!`jj log --limit 10` +</context> + +<process> +BEFORE CREATING - you MUST: +1. List any related existing tasks from context above (or state "no related tasks") +2. State which revision you'll use as parent and WHY + +THEN create: + +3. Run: `jjtask create $ARGUMENTS` + - One arg: `jjtask create "title"` (parent = @) + - Two args: `jjtask create parent "title"` + - Three args: `jjtask create parent "title" "description"` +</process> + +<success_criteria> +- Stated related existing tasks (or "none") +- Stated parent choice with reasoning +- New todo revision created +</success_criteria> diff --git a/.dev-backup/commands/desc-transform.md b/.dev-backup/commands/desc-transform.md new file mode 100644 index 0000000..206db0b --- /dev/null +++ b/.dev-backup/commands/desc-transform.md @@ -0,0 +1,16 @@ +--- +description: Transform a revision description with sed +argument-hint: <rev> <sed-expr> +allowed-tools: + - Bash +--- + +<objective> +Pipe a revision's description through sed and update it. + +Example: `jjtask desc-transform @ 's/foo/bar/'` +</objective> + +<process> +Run: `jjtask desc-transform $ARGUMENTS` +</process> diff --git a/claude-plugin/commands/finalize.md b/.dev-backup/commands/finalize.md similarity index 100% rename from claude-plugin/commands/finalize.md rename to .dev-backup/commands/finalize.md diff --git a/.dev-backup/commands/find.md b/.dev-backup/commands/find.md new file mode 100644 index 0000000..faecd15 --- /dev/null +++ b/.dev-backup/commands/find.md @@ -0,0 +1,30 @@ +--- +description: Find revisions with specific task flags +argument-hint: [flag] +allowed-tools: + - Skill(jjtask) + - Bash +--- + +<objective> +List task revisions filtered by status flag. + +Without arguments: shows all pending tasks. +With flag: shows only tasks with that flag (todo, wip, done, blocked, etc.) + +Part of `/jjtask` - run that skill for full workflow context. +</objective> + +<context> +!`jjtask find $ARGUMENTS` +</context> + +<process> +1. Review the task list above +2. Suggest next actions based on task states +</process> + +<success_criteria> +- Task list displayed +- Actionable suggestions provided +</success_criteria> diff --git a/.dev-backup/commands/flag.md b/.dev-backup/commands/flag.md new file mode 100644 index 0000000..37a114b --- /dev/null +++ b/.dev-backup/commands/flag.md @@ -0,0 +1,30 @@ +--- +description: Update task flag on a revision +argument-hint: <rev> <flag> +allowed-tools: + - Skill(jjtask) + - Bash +--- + +<objective> +Change the task status flag on a revision. + +Flags: draft, todo, wip, blocked, standby, untested, review, done + +Part of `/jjtask` - run that skill for full workflow context. +</objective> + +<context> +Current tasks: +!`jjtask find 2>/dev/null | head -20` +</context> + +<process> +1. Run: `jjtask flag $ARGUMENTS` +2. Confirm the flag was updated +</process> + +<success_criteria> +- Task flag updated +- No conflicts created +</success_criteria> diff --git a/claude-plugin/commands/next.md b/.dev-backup/commands/next.md similarity index 100% rename from claude-plugin/commands/next.md rename to .dev-backup/commands/next.md diff --git a/claude-plugin/commands/parallel-recover.md b/.dev-backup/commands/parallel-recover.md similarity index 100% rename from claude-plugin/commands/parallel-recover.md rename to .dev-backup/commands/parallel-recover.md diff --git a/claude-plugin/commands/parallel-start.md b/.dev-backup/commands/parallel-start.md similarity index 100% rename from claude-plugin/commands/parallel-start.md rename to .dev-backup/commands/parallel-start.md diff --git a/claude-plugin/commands/parallel-status.md b/.dev-backup/commands/parallel-status.md similarity index 100% rename from claude-plugin/commands/parallel-status.md rename to .dev-backup/commands/parallel-status.md diff --git a/claude-plugin/commands/parallel-stop.md b/.dev-backup/commands/parallel-stop.md similarity index 100% rename from claude-plugin/commands/parallel-stop.md rename to .dev-backup/commands/parallel-stop.md diff --git a/claude-plugin/commands/parallel.md b/.dev-backup/commands/parallel.md similarity index 100% rename from claude-plugin/commands/parallel.md rename to .dev-backup/commands/parallel.md diff --git a/.dev-backup/commands/reorganize.md b/.dev-backup/commands/reorganize.md new file mode 100644 index 0000000..b6839af --- /dev/null +++ b/.dev-backup/commands/reorganize.md @@ -0,0 +1,41 @@ +--- +description: Analyze task DAG and suggest reorganization +allowed-tools: + - Skill(jjtask) + - Bash + - Read +--- + +<objective> +Review the current task DAG structure and suggest improvements: +- Identify dependency issues (task mentions another but isn't a child) +- Find parallelization opportunities (independent tasks that could run concurrently) +- Detect structural problems (orphaned tasks, blocked children, incomplete drafts) + +Part of `/jjtask` - run that skill for full workflow context. +</objective> + +<context> +Current task DAG: +!`jjtask find 2>/dev/null || echo "no tasks"` + +Task descriptions (for dependency analysis): +!`for rev in $(jj log -r 'tasks_pending()' -T 'change_id.shortest() ++ "\n"' --no-graph 2>/dev/null | head -10); do echo "=== $rev ==="; jjtask show-desc "$rev" 2>/dev/null | head -20; echo; done` +</context> + +<process> +1. Review the DAG structure above +2. Read task descriptions looking for dependency keywords: "after", "requires", "depends on", "needs", "once X is done" +3. Identify issues: + - Tasks referencing others that aren't ancestors + - Sequential tasks that could be parallel + - Orphaned tasks needing hoist +4. Propose concrete rebase commands for each issue +5. Execute rebases only with user confirmation +</process> + +<success_criteria> +- DAG analyzed for dependency/structure issues +- Concrete rebase commands proposed (if issues found) +- No rebases executed without user approval +</success_criteria> diff --git a/.dev-backup/commands/show-desc.md b/.dev-backup/commands/show-desc.md new file mode 100644 index 0000000..2694e3a --- /dev/null +++ b/.dev-backup/commands/show-desc.md @@ -0,0 +1,14 @@ +--- +description: Show revision description +argument-hint: [rev] +allowed-tools: + - Bash +--- + +<objective> +Print the description of a revision. Defaults to @ if no rev specified. +</objective> + +<process> +Run: `jjtask show-desc $ARGUMENTS` +</process> diff --git a/.dev-backup/skills/jj/SKILL.md b/.dev-backup/skills/jj/SKILL.md new file mode 100644 index 0000000..f6b5e59 --- /dev/null +++ b/.dev-backup/skills/jj/SKILL.md @@ -0,0 +1,196 @@ +--- +name: jj +description: Expert guidance for using JJ (Jujutsu) version control system. Use when user mentions jj, jujutsu, or needs version control in JJ-managed repositories. Covers commands, revsets, templates, evolog, operations log, and git interop. +--- + +<objective> +Provide expert guidance for JJ version control operations. Help users understand JJ's mental model (immutable change IDs, auto-snapshots, conflicts don't block) and execute commands correctly. +</objective> + +<quick_start> + +```bash +jj log -r <revset> [-p] # View history (-p: diffs) +jj show -r <rev> # Revision details +jj new [-A] <base> # Create revision (-A: insert after) +jj edit <rev> # Switch to revision +jj desc -r <rev> -m "text" # Set description +jj diff # Changes in @ +jj restore <fileset> # Discard changes +jj rebase -s <src> -o <dest> # Rebase onto dest +jj undo # Undo last operation +``` +</quick_start> + +<success_criteria> +- JJ operation completed without error +- Working copy (@) in expected state +- Revision graph reflects intended structure +- For rebases/splits: no unintended conflicts introduced +</success_criteria> + +<core_principles> +- Change IDs (immutable) vs Commit IDs (content-hash, changes on edit) +- Operations log: every operation can be undone (progressive `jj undo`, `jj redo` reverses) +- No staging area: working copy auto-snapshots +- Conflicts don't block: resolve later +- Commits are lightweight: edit freely +- Colocated by default: Git repos have both `.jj` and `.git` (since v0.34) +- Three DSLs: + - revsets: select revisions (a change ID is a valid singleton revset) + - filesets: select files (a filepath is a valid singleton fileset) + - templates: control output format +</core_principles> + +<essential_commands> + +```bash +jj log -r <revset> [-p] # View history (--patch/-p: include diffs) +jj log -r <revset> -G # -G is short for --no-graph +jj show -r <rev> # Show revision details (description + diff) +jj evolog -r <rev> [-p] # View a revision's evolution +jj new [-A] <base> # Create revision and edit it (-A: insert after base) +jj new --no-edit <base> # Create without switching +jj edit <rev> # Switch to editing revision +jj desc -r <rev> -m "text" # Set description +jj metaedit -r <rev> -m "text" # Modify metadata (author, timestamps, description) + +jj diff # Changes in @ +jj diff -r <rev> # Changes in revision +jj file show -r <rev> <fileset> # Show file contents at revision +jj restore <fileset> # Discard changes to files +jj restore --from <commit-id> <fileset> # Restore from another revision + +jj split -r <rev> <paths> -m "text" # Split into two revisions +jj absorb # Auto-squash changes into ancestor commits + +jj rebase -s <src> -o <dest> # Rebase with descendants onto dest +jj rebase -r <rev> -o <dest> # Rebase single revision onto dest +jj rebase -s 'a | b | c' -o <dest> # Batch: multiple roots via revset union + +jj file annotate <path> # Blame: who changed each line +jj bisect run -- <cmd> # Binary search for bug-introducing commit +``` +</essential_commands> + +<additional_commands> + +```bash +jj undo # Undo last operation (progressive) +jj redo # Redo undone operation +jj sign -r <rev> # Cryptographically sign commit +jj unsign -r <rev> # Remove signature +jj revert -r <rev> # Create commit that reverts changes +jj tag set <name> -r <rev> # Create/update local tag +jj tag delete <name> # Delete local tag +jj git colocation enable # Convert to colocated repo +jj git colocation disable # Convert to non-colocated +``` +</additional_commands> + +<revset_reference> +```bash +@, @-, @-- # Working copy, parent(s), grandparent(s) +::@ # Ancestors +@:: # Descendants +mine() # Your changes +conflicted() # Has conflicts +visible() # Visible revisions +hidden() # Hidden revisions +description(substring-i:"text") # Match description (partial, case-insensitive) +subject(substring:"text") # Match first line only +signed() # Cryptographically signed commits +A | B, A & B, A ~ B # Union, intersection, difference +change_id(prefix) # Explicit change ID prefix lookup +parents(x, 2) # Parents with depth +exactly(x, 3) # Assert exactly N revisions +``` +</revset_reference> + +<anti_patterns> +<pitfall name="use-short-flag"> +Use `-r` not `--revisions`: +```bash +jj log -r xyz # correct +jj log --revisions xyz # error +``` +</pitfall> + +<pitfall name="parallel-branches"> +Use `--no-edit` for parallel branches: +```bash +jj new parent -m "A"; jj new -m "B" # B is child of A +jj new --no-edit parent -m "A"; jj new --no-edit parent -m "B" # Both children of parent +``` +</pitfall> + +<pitfall name="quote-revsets"> +Quote revsets in shell: +```bash +jj log -r 'description(substring:"[todo]")' +``` +</pitfall> + +<pitfall name="use-onto"> +Use `-o`/`--onto` instead of deprecated `-d`/`--destination` (v0.36+): +```bash +jj rebase -s xyz -o main # correct +jj rebase -s xyz -d main # deprecated +``` +</pitfall> + +<pitfall name="symbol-expressions"> +Symbol expressions are stricter (v0.32+). Revset symbols no longer resolve to multiple revisions: +```bash +jj log -r abc # error if 'abc' matches multiple change IDs +jj log -r 'change_id(abc)' # explicit prefix query +jj log -r 'bookmarks(abc)' # for bookmark name patterns +``` +</pitfall> + +<pitfall name="glob-patterns"> +Glob patterns are default in filesets (v0.36+): +```bash +jj diff 'src/*.rs' # matches glob pattern by default +jj diff 'cwd:"src/*.rs"' # use cwd: prefix for literal path +``` +</pitfall> + +<pitfall name="description-glob-brackets"> +Don't use glob with `[` brackets in description() - they're character classes: +```bash +jj log -r 'description(glob:"*[task:*]*")' # WRONG - [task:*] is char class +jj log -r 'description(substring:"[task:")' # CORRECT +jj log -r 'tasks()' # BEST - use alias +``` +</pitfall> +</anti_patterns> + +<scripts> +Helper scripts: + +| Script | Purpose | +| ----------------------------- | ------------------------------------ | +| `jjtask show-desc [REV]` | Print full description only | +| `jjtask desc-transform REV SED` | Pipe description through command | +| `jjtask batch-desc SED REVSET` | Batch transform descriptions | +| `jjtask checkpoint [NAME]` | Record op ID before risky operations | +</scripts> + +<recovery> + +```bash +jj op log # Find operation before problem +jj op restore <op-id> # Restore WHOLE repository to that state +``` +</recovery> + +<reference_guides> +- Run `jj help -k bookmarks` - bookmarks, git branches, push/fetch +- Run `jj help -k revsets` - revset DSL syntax +- Run `jj help -k filesets` - filepath selection DSL +- Run `jj help -k templates` - template language +- All subcommands have detailed `--help` +- `references/command-syntax.md` - command flag details +- `references/batch-operations.md` - batch transformations +</reference_guides> diff --git a/.dev-backup/skills/jj/references/batch-operations.md b/.dev-backup/skills/jj/references/batch-operations.md new file mode 100644 index 0000000..be4187a --- /dev/null +++ b/.dev-backup/skills/jj/references/batch-operations.md @@ -0,0 +1,81 @@ +# Batch Description Operations + +Updating descriptions for multiple revisions requires careful bash syntax. + +## Anti-pattern + +```bash +for rev in abc def ghi; do + jj log -r $rev | sed 's/old/new/' | jj desc -r $rev --stdin +done +``` + +Issues: +1. Missing `-n1 --no-graph -T description` (gets formatted log) +2. Unquoted variables can break with special chars +3. Complex pipes are fragile + +## Pattern: Intermediate Files + +```bash +for rev in abc def ghi; do + jj log -r "$rev" -n1 --no-graph -T description > /tmp/desc_${rev}_old.txt + sed 's/old/new/' /tmp/desc_${rev}_old.txt > /tmp/desc_${rev}_new.txt + jj desc -r "$rev" --stdin < /tmp/desc_${rev}_new.txt +done +``` + +Benefits: +- Each step visible and debuggable +- Can inspect intermediate files +- Easy to retry individual revisions + +## Pattern: Sed Script File + +```bash +cat > /tmp/replacements.sed << 'EOF' +s/pattern1/replacement1/g +s/pattern2/replacement2/g +EOF + +for rev in abc def ghi; do + jj log -r "$rev" -n1 --no-graph -T description | \ + sed -f /tmp/replacements.sed | \ + jj desc -r "$rev" --stdin +done +``` + +## Using jjtask batch-desc + +The `jjtask batch-desc` helper simplifies this: + +```bash +jjtask batch-desc 's/old/new/' 'tasks_pending()' +``` + +## Common Mistakes + +```bash +# Wrong: gets formatted log +jj log -r xyz | sed 's/old/new/' + +# Correct: gets raw description +jj log -r xyz -n1 --no-graph -T description | sed 's/old/new/' + +# Wrong: unquoted variables +for rev in a b c; do jj log -r $rev; done + +# Correct: always quote +for rev in a b c; do jj log -r "$rev"; done +``` + +## Verification + +Always verify after batch operations: + +```bash +for rev in abc def ghi; do + echo "=== $rev ===" + jj log -r "$rev" -n1 --no-graph -T description | head -3 +done +``` diff --git a/.dev-backup/skills/jj/references/command-syntax.md b/.dev-backup/skills/jj/references/command-syntax.md new file mode 100644 index 0000000..0f85018 --- /dev/null +++ b/.dev-backup/skills/jj/references/command-syntax.md @@ -0,0 +1,105 @@ +# JJ Command Syntax Reference + +## Flag Usage + +JJ commands are inconsistent with flag naming. For most commands, use `-r` only: + +```bash +jj log -r <revset> # correct +jj desc -r <revset> # correct +jj show -r <revset> # correct +jj rebase -r <revset> # correct +jj edit -r <revset> # correct (no --revision) +``` + +Common mistake: +```bash +jj desc --revisions xyz # error +jj log --revision xyz # error +jj desc -r xyz # correct +``` + +## Common Short Flags + +```bash +-G # --no-graph (v0.35+) +-o # --onto (replaces -d in v0.36+) +-f / -t # --from / --to +``` + +## Reading Revision Info + +```bash +jj log -r <rev> -n1 --no-graph -T description # description only +jj log -r <rev> -n1 --no-graph -T builtin_log_detailed # detailed info +jj log -r <rev> -T 'change_id.shortest(4) ++ " " ++ description.first_line()' +``` + +## Modifying Revisions + +```bash +jj desc -r <rev> -m "New description" # from string +echo "New description" | jj desc -r <rev> --stdin # from stdin +jj desc -r <rev> --stdin < /path/to/description.txt # from file + +jj log -r <rev> -n1 --no-graph -T description | \ + sed 's/old/new/' | \ + jj desc -r <rev> --stdin # pipeline pattern +``` + +## Creating Revisions + +```bash +jj new <parent> -m "Description" # create and edit (moves @) +jj new --no-edit <parent> -m "Description" # create without editing +jj new --no-edit <parent1> <parent2> -m "Merge point" # merge with multiple parents +``` + +## Revset Syntax + +Basic: +```bash +@ # working copy +<change-id> # specific revision +``` + +Operators: +```bash +<rev>::<rev> # range (inclusive) +<rev>.. # all descendants +..<rev> # all ancestors +::@ # all ancestors of @ +``` + +Functions: +```bash +description(glob:"pattern") +description(exact:"text") +description(substring:"text") +mine() +``` + +Combining: +```bash +rev1 | rev2 # union (OR) +rev1 & rev2 # intersection (AND) +``` + +## Shell Quoting + +Revsets often need quoting: +```bash +jj log -r 'description(glob:"[todo]*")' # single quotes (safest) +``` + +## Quick Reference + +| Task | Command | +| ---------------- | ----------------------------------------------- | +| View description | `jj log -r <rev> -n1 --no-graph -T description` | +| Set description | `jj desc -r <rev> -m "text"` | +| Set from stdin | `jj desc -r <rev> --stdin` | +| Create (edit) | `jj new <parent> -m "text"` | +| Create (no edit) | `jj new --no-edit <parent> -m "text"` | +| Range query | `jj log -r '<from>::<to>'` | +| Find pattern | `jj log -r 'description(glob:"pat*")'` | diff --git a/.dev-backup/skills/jjtask/SKILL.md b/.dev-backup/skills/jjtask/SKILL.md new file mode 100644 index 0000000..88a251a --- /dev/null +++ b/.dev-backup/skills/jjtask/SKILL.md @@ -0,0 +1,309 @@ +--- +name: jjtask +description: Structured TODO commit workflow using JJ (Jujutsu). Use to plan tasks as empty commits with [task:*] flags, track progress through status transitions, manage parallel task DAGs with dependency checking. Enforces completion discipline. Enables to divide work between Planners and Workers. +version_target: "0.36.x" +--- + +<objective> +Manage a DAG of empty revisions as TODO markers representing tasks. Revision descriptions act as specifications. Two roles: Planners (create empty revisions with specs) and Workers (implement them). For JJ basics (revsets, commands, recovery), see the `/jj` skill. +</objective> + +<quick_start> + +```bash +# 1. Plan: Create TODO chain (parent required - ensures clean DAG) +jjtask create @ "Add user validation" "Check email format and password strength" +# Created: abc123 as child of @ + +jjtask create abc123 "Add validation tests" "Test valid/invalid emails and passwords" +# Created: def456 as child of abc123 -> forms chain: @ -> abc123 -> def456 + +# 2. Start working +jj edit abc123 +jjtask flag @ wip + +# ... implement validation ... + +# 3. Review specs and move to next +jjtask next +# Shows current specs and available next tasks + +# 4. Mark done and continue +jjtask next --mark-as done def456 # Marks abc123 done, starts def456 +``` +</quick_start> + +<success_criteria> +- Task created with correct parent relationship +- Status flags reflect actual task state +- DAG shows clear priority (chained tasks) and parallelism (sibling tasks) +- All acceptance criteria met before marking done +- No orphaned tasks far from @ +</success_criteria> + +<status_flags> + +| Flag | Meaning | +| ----------------- | -------------------------------------------- | +| `[task:draft]` | Placeholder, needs full specification | +| `[task:todo]` | Ready to work, complete specs | +| `[task:wip]` | Work in progress | +| `[task:blocked]` | Waiting on external dependency | +| `[task:standby]` | Awaits decision | +| `[task:untested]` | Implementation done, needs testing | +| `[task:review]` | Needs review | +| `[task:done]` | Complete, all acceptance criteria met | + +Progression: `draft` -> `todo` -> `wip` -> `done` + +```bash +jjtask flag @ wip # Start work +jjtask flag @ untested # Implementation done +jjtask flag @ done # Complete +``` +</status_flags> + +<description_management> + +Flag changes only update status. To modify description content: + +```bash +# Add completion notes when marking done +jjtask flag @ done +jj desc -r @ -m "$(jjtask show-desc @) + +## Completion +- What was done +- Deviations from spec" + +# Check off acceptance criteria +jjtask desc-transform @ 's/- \[ \] First criterion/- [x] First criterion/' + +# Append a section +jjtask desc-transform @ 's/$/\n\n## Notes\nAdditional context here/' + +# Batch update multiple tasks +jjtask batch-desc 's/old-term/new-term/g' 'tasks_todo()' +``` + +When to use what: +- `jjtask flag` - status only +- `jj desc -r REV -m "..."` - replace entire description +- `jjtask desc-transform` - partial find/replace with sed +- `jjtask batch-desc` - same transform across multiple tasks +</description_management> + +<finding_tasks> + +```bash +jjtask find # Pending tasks with DAG structure +jjtask find todo # Only [task:todo] +jjtask find wip # Only [task:wip] +jjtask find done # Only [task:done] +jjtask find all # All tasks including done +``` +</finding_tasks> + +<parallel_tasks> + +```bash +# Create parallel branches +jjtask parallel <parent-id> "Widget A" "Widget B" "Widget C" + +# Merge point (all parents must complete) +jj new --no-edit <A-id> <B-id> <C-id> -m "[task:todo] Integration\n\n..." +``` +</parallel_tasks> + +<todo_description_format> + +``` +Short title (< 50 chars) + +## Context +Why this task exists, what problem it solves. + +## Requirements +- Specific requirement 1 +- Specific requirement 2 + +## Acceptance criteria +- Criterion 1 (testable) +- Criterion 2 (testable) +``` +</todo_description_format> + +<completion_discipline> + +Do NOT mark done unless ALL acceptance criteria are met. + +Mark done when: +- Every requirement implemented +- All acceptance criteria pass +- Tests pass + +Never mark done when: +- "Good enough" or "mostly works" +- Tests failing +- Partial implementation +</completion_discipline> + +<anti_patterns> +<pitfall name="stop-and-report"> +If you encounter these issues, STOP and report: +- Made changes in wrong revision +- Previous work needs fixes +- Uncertain about how to proceed +- Dependencies unclear + +Do NOT attempt to fix using JJ operations not in this workflow. +</pitfall> +</anti_patterns> + +<dag_validation> + +When reviewing tasks with `jjtask find`, look for structural issues: + +Good DAG - chained tasks show priority, parallel tasks are siblings: +``` +o E [todo] Feature complete <- gate: all children done, tests pass, reviewed +|-+-, +| | o D2 [todo] Write docs <- parallel with D1 +| o | D1 [todo] Add tests <- parallel with D2 +|-' | +o | C [todo] Implement <- after B +o -' B [todo] Design API <- after A +o A [todo] Research <- do first +@ current work +``` +Reading bottom-up: A -> B -> C -> (D1 || D2) -> E (gate) + +Task E is a "gate" - marks feature complete only when all children done. + +Bad DAG - all siblings, no priority visible: +``` +| o E [todo] Deploy +|-' +| o D [todo] Write docs +|-' +| o C [todo] Implement +|-' +| o B [todo] Design API +|-' +| o A [todo] Research +|-' +@ current work +``` +Problem: Which task comes first? No way to tell. +Fix: Chain dependent tasks with `jj rebase -s B -o A` + +Dependency problems: +- Task mentions another task but isn't a child of it -> `jj rebase -s TASK -o DEPENDENCY` +- Task requires output from another but they're siblings -> rebase to make sequential +- Keywords: "after", "requires", "depends on", "once X is done", "needs" + +Parallelization opportunities: +- Sequential tasks that don't share state -> could be parallel siblings +- Independent features under same parent -> good candidates for parallel agents + +Structural issues: +- Orphaned tasks far from @ -> `jjtask hoist` or manual rebase +- Done tasks with pending children -> children may be blocked +- Draft tasks mixed with todo -> drafts need specs before work begins +</dag_validation> + +<hoisting> + +When you make commits, tasks created earlier stay behind. Run `jjtask hoist` to move pending tasks up: + +```bash +jjtask hoist +# Moves pending task roots to be children of @ +``` +</hoisting> + +<finalizing> + +```bash +# Strip [task:done] prefix for final commit +jjtask finalize @ +``` +</finalizing> + +<commands> + +| Command | Purpose | +| ----------------------------------- | --------------------------------- | +| `jjtask create PARENT TITLE [DESC]` | Create TODO as child of PARENT | +| `jjtask parallel PARENT T1 T2...` | Create parallel TODOs | +| `jjtask next [--mark-as STATUS] [REV]` | Review specs, optionally move | +| `jjtask flag REV FLAG` | Update status flag | +| `jjtask find [FLAG] [-r REVSET]` | Find tasks (flags or custom revset)| +| `jjtask hoist` | Rebase pending tasks to @ | +| `jjtask finalize [REV]` | Strip task prefix for final commit| +| `jjtask show-desc [REV]` | Print revision description | +| `jjtask desc-transform REV SED` | Transform description with sed | +| `jjtask batch-desc SED REVSET` | Transform multiple descriptions | +| `jjtask checkpoint [NAME]` | Create named checkpoint | +| `jjtask all <cmd> [args]` | Run jj command across all repos | +| `jjtask prime` | Output session context for hooks | +| `jjtask parallel-start [--mode] TASK` | Start parallel agent session | +| `jjtask parallel-stop [TASK]` | Stop parallel session | +| `jjtask parallel-status [TASK]` | Show parallel session status | +| `jjtask agent-context ID` | Get context for parallel agent | +</commands> + +<multi_repo> + +Create `.jj-workspaces.yaml` in project root: + +```yaml +repos: + - path: frontend + name: frontend + - path: backend + name: backend +``` + +Scripts show output grouped by repo. Use `jjtask all log` or `jjtask all diff` across repos. +</multi_repo> + +<parallel_agents> + +Multiple Claude agents can work simultaneously on the same repo. + +Detecting parallel context - check `jjtask prime` output for "Parallel Session Active" section, or run: +```bash +jjtask agent-context <your-agent-id> +``` + +Rules for parallel work: +1. ONLY modify files in your assignment - other files belong to other agents +2. Check assignments: `jjtask parallel-status` +3. Mark your task done when complete: `jjtask flag @ done` + +Shared mode (agents share @): +- Your changes appear immediately to others +- File discipline critical - stay in your assigned patterns +- Conflicts possible if patterns overlap + +Workspace mode (isolated directories): +- You have your own working copy in `.jjtask-workspaces/<agent>/` +- Other agents can't affect your files +- Complete isolation until session ends + +Commands: +```bash +jjtask agent-context <id> # Your assignment and context +jjtask parallel-status # All agents' progress +jjtask parallel-recover # Fix workspace issues +``` + +See `references/parallel-agents.md` for full documentation. +</parallel_agents> + +<references> +- `references/parallel-agents.md` - Multi-agent parallel execution +- `references/batch-operations.md` - Batch description transformations +- `references/command-syntax.md` - JJ command flag details +</references> diff --git a/.dev-backup/skills/jjtask/references/batch-operations.md b/.dev-backup/skills/jjtask/references/batch-operations.md new file mode 100644 index 0000000..dc49d13 --- /dev/null +++ b/.dev-backup/skills/jjtask/references/batch-operations.md @@ -0,0 +1,81 @@ +<batch_operations> +Updating descriptions for multiple revisions requires careful bash syntax. + +<anti_pattern> +```bash +for rev in abc def ghi; do + jj log -r $rev | sed 's/old/new/' | jj desc -r $rev --stdin +done +``` + +Issues: +1. Missing `-n1 --no-graph -T description` (gets formatted log) +2. Unquoted variables can break with special chars +3. Complex pipes are fragile +</anti_pattern> + +<pattern name="intermediate_files"> +```bash +for rev in abc def ghi; do + jj log -r "$rev" -n1 --no-graph -T description > /tmp/desc_${rev}_old.txt + sed 's/old/new/' /tmp/desc_${rev}_old.txt > /tmp/desc_${rev}_new.txt + jj desc -r "$rev" --stdin < /tmp/desc_${rev}_new.txt +done +``` + +Benefits: +- Each step visible and debuggable +- Can inspect intermediate files +- Easy to retry individual revisions +</pattern> + +<pattern name="sed_script_file"> +```bash +cat > /tmp/replacements.sed << 'EOF' +s/pattern1/replacement1/g +s/pattern2/replacement2/g +EOF + +for rev in abc def ghi; do + jj log -r "$rev" -n1 --no-graph -T description | \ + sed -f /tmp/replacements.sed | \ + jj desc -r "$rev" --stdin +done +``` +</pattern> + +<jjtask_helper> +The `jjtask batch-desc` helper simplifies this: + +```bash +jjtask batch-desc 's/old/new/' 'tasks_pending()' +``` +</jjtask_helper> + +<common_mistakes> +```bash +# Wrong: gets formatted log +jj log -r xyz | sed 's/old/new/' + +# Correct: gets raw description +jj log -r xyz -n1 --no-graph -T description | sed 's/old/new/' + +# Wrong: unquoted variables +for rev in a b c; do jj log -r $rev; done + +# Correct: always quote +for rev in a b c; do jj log -r "$rev"; done +``` +</common_mistakes> + +<verification> +Always verify after batch operations: + +```bash +for rev in abc def ghi; do + echo "=== $rev ===" + jj log -r "$rev" -n1 --no-graph -T description | head -3 +done +``` +</verification> +</batch_operations> diff --git a/.dev-backup/skills/jjtask/references/command-syntax.md b/.dev-backup/skills/jjtask/references/command-syntax.md new file mode 100644 index 0000000..397bcbd --- /dev/null +++ b/.dev-backup/skills/jjtask/references/command-syntax.md @@ -0,0 +1,105 @@ +<command_syntax> +<flag_usage> +JJ commands are inconsistent with flag naming. For most commands, use `-r` only: + +```bash +jj log -r <revset> # correct +jj desc -r <revset> # correct +jj show -r <revset> # correct +jj rebase -r <revset> # correct +jj edit -r <revset> # correct (no --revision) +``` + +Common mistake: +```bash +jj desc --revisions xyz # error +jj log --revision xyz # error +jj desc -r xyz # correct +``` +</flag_usage> + +<short_flags> +```bash +-G # --no-graph (v0.35+) +-o # --onto (replaces -d in v0.36+) +-f / -t # --from / --to +``` +</short_flags> + +<reading_revisions> +```bash +jj log -r <rev> -n1 --no-graph -T description # description only +jj log -r <rev> -n1 --no-graph -T builtin_log_detailed # detailed info +jj log -r <rev> -T 'change_id.shortest(4) ++ " " ++ description.first_line()' +``` +</reading_revisions> + +<modifying_revisions> +```bash +jj desc -r <rev> -m "New description" # from string +echo "New description" | jj desc -r <rev> --stdin # from stdin +jj desc -r <rev> --stdin < /path/to/description.txt # from file + +jj log -r <rev> -n1 --no-graph -T description | \ + sed 's/old/new/' | \ + jj desc -r <rev> --stdin # pipeline pattern +``` +</modifying_revisions> + +<creating_revisions> +```bash +jj new <parent> -m "Description" # create and edit (moves @) +jj new --no-edit <parent> -m "Description" # create without editing +jj new --no-edit <parent1> <parent2> -m "Merge point" # merge with multiple parents +``` +</creating_revisions> + +<revset_syntax> +Basic: +```bash +@ # working copy +<change-id> # specific revision +``` + +Operators: +```bash +<rev>::<rev> # range (inclusive) +<rev>.. # all descendants +..<rev> # all ancestors +::@ # all ancestors of @ +``` + +Functions: +```bash +description(glob:"pattern") +description(exact:"text") +description(substring:"text") +mine() +``` + +Combining: +```bash +rev1 | rev2 # union (OR) +rev1 & rev2 # intersection (AND) +``` +</revset_syntax> + +<shell_quoting> +Revsets often need quoting: +```bash +jj log -r 'description(glob:"[todo]*")' # single quotes (safest) +``` +</shell_quoting> + +<quick_reference> +| Task | Command | +| ---------------- | ----------------------------------------------- | +| View description | `jj log -r <rev> -n1 --no-graph -T description` | +| Set description | `jj desc -r <rev> -m "text"` | +| Set from stdin | `jj desc -r <rev> --stdin` | +| Create (edit) | `jj new <parent> -m "text"` | +| Create (no edit) | `jj new --no-edit <parent> -m "text"` | +| Range query | `jj log -r '<from>::<to>'` | +| Find pattern | `jj log -r 'description(glob:"pat*")'` | +</quick_reference> +</command_syntax> diff --git a/claude-plugin/skills/jjtask/references/parallel-agents.md b/.dev-backup/skills/jjtask/references/parallel-agents.md similarity index 100% rename from claude-plugin/skills/jjtask/references/parallel-agents.md rename to .dev-backup/skills/jjtask/references/parallel-agents.md diff --git a/.gitignore b/.gitignore index 9470104..eeb550d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ bin/jjtask-go claude-plugin/bin/jjtask-go /jjtask tmp +.dev-backup diff --git a/.mise.toml b/.mise.toml index 7db1fad..390c015 100644 --- a/.mise.toml +++ b/.mise.toml @@ -8,9 +8,9 @@ description = "Build the jjtask binary" run = "go build -o bin/jjtask-go ./cmd/jjtask" [tasks.test] -description = "Run integration tests" +description = "Run Go integration tests" depends = ["build"] -run = "./test.sh" +run = "go test ./cmd/jjtask/cmd/..." [tasks.lint] description = "Run golangci-lint" diff --git a/CLAUDE.md b/CLAUDE.md index 88fcd9e..c972fde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,9 @@ jjtask is a portable Claude Code plugin for structured task management using JJ jjtask/ ├── cmd/jjtask/ # Go CLI source │ ├── main.go # Entry point -│ └── cmd/ # Cobra commands (find, create, flag, next, etc.) +│ └── cmd/ # Cobra commands (wip, done, drop, squash, find, create, etc.) ├── internal/ # Go internal packages │ ├── jj/ # JJ interaction layer -│ ├── parallel/ # Parallel agent session management │ └── workspace/ # Multi-workspace support ├── bin/ │ ├── jjtask # Dispatcher (downloads/runs jjtask-go) @@ -31,25 +30,23 @@ jjtask/ ├── shell/fish/ │ ├── completions/ # Generated fish completions │ └── functions/ # jjtask-env.fish shell setup -├── test/ # Integration tests and snapshots +├── test/ # Test snapshots (test/snapshots_go/) ├── .github/workflows/ # CI and release automation ├── install.sh # Installer (builds Go, symlinks, completions) -├── test.sh # Integration test runner └── .mise.toml # Toolchain and tasks (Go 1.25, golangci-lint) ``` ## Multi-Workspace Support -For projects with multiple jj repos, create `.jj-workspaces.yaml` in project root: +For projects with multiple jj repos, create `.jjtask.toml` in project root: -```yaml -repos: - - path: frontend - name: frontend - - path: backend - name: backend - - path: . - name: root +```toml +[workspaces] +repos = [ + { path = "frontend", name = "frontend" }, + { path = "backend", name = "backend" }, + { path = ".", name = "root" }, +] ``` Scripts auto-detect this config and operate across all repos: @@ -81,10 +78,11 @@ mise run dev # Dev setup: symlinks + completions Manual workflow: ```bash -go build -o bin/jjtask-go ./cmd/jjtask # Build -./test.sh # Test -./install.sh # Install to ~/.local/bin -./install.sh --uninstall # Remove +go build -o bin/jjtask-go ./cmd/jjtask # Build +go test ./cmd/jjtask/cmd/... # Test all +go test ./cmd/jjtask/cmd/... -v -run TestCreate # Filter tests +./install.sh # Install to ~/.local/bin +./install.sh --uninstall # Remove ``` ## Releasing @@ -105,8 +103,8 @@ This will: - Go code in `cmd/jjtask/cmd/` for commands, `internal/` for shared packages - Use Cobra for CLI structure with persistent flags for JJ globals (-R, --quiet) - Prefer `change_id.shortest()` over `change_id.short()` in templates -- Integration tests use snapshot comparison (test/snapshots/) -- Update snapshots with `SNAPSHOT_UPDATE=1 ./test.sh` +- Integration tests use snapshot comparison (test/snapshots_go/) +- Update snapshots with `SNAPSHOT_UPDATE=1 go test ./cmd/jjtask/cmd/...` ## Creating Skills/Commands diff --git a/README.md b/README.md index 1481ea5..ecee5f0 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,18 @@ claude plugin install jjtask@jjtask-marketplace ## Workflow -jjtask enables a two-role workflow: Planners create task specifications, Workers implement them. +jjtask uses a "mega-merge" model: @ is always a merge of all active work. ``` ┌─────────────────────────────────────────────────────────────┐ │ PLANNING PHASE │ ├─────────────────────────────────────────────────────────────┤ │ 1. Create task DAG with specifications │ -│ jj task create @ "Add user auth" "## Requirements..." │ -│ jj task parallel @ "Frontend" "Backend" "Tests" │ +│ jjtask create "Add user auth" "## Requirements..." │ +│ jjtask parallel "Frontend" "Backend" "Tests" │ │ │ │ 2. Review structure │ -│ jj task find │ +│ jjtask find │ │ │ │ Result: Empty revisions with [task:todo] flags │ └─────────────────────────────────────────────────────────────┘ @@ -44,27 +44,26 @@ jjtask enables a two-role workflow: Planners create task specifications, Workers ┌─────────────────────────────────────────────────────────────┐ │ WORKING PHASE │ ├─────────────────────────────────────────────────────────────┤ -│ 3. Pick a task and start working │ -│ jj edit <task-id> │ -│ jj task flag @ wip │ +│ 3. Start working on a task (@ becomes merge of active) │ +│ jjtask wip <task-id> │ │ │ -│ 4. Implement according to specs in description │ +│ 4. Work directly in @ - changes go to merged tasks │ │ # write code, make changes │ │ │ -│ 5. Review specs, check acceptance criteria │ -│ jj task next # shows current specs │ +│ 5. Complete task when ALL criteria met │ +│ jjtask done │ │ │ -│ 6. Transition when ALL criteria met │ -│ jj task next --mark-as done <next-task> │ +│ 6. Ready to push? Flatten the merge │ +│ jjtask squash │ └─────────────────────────────────────────────────────────────┘ ``` ### Workflow Rules - Never mark `done` unless ALL acceptance criteria pass -- Use `blocked`, `review`, or `untested` if criteria aren't fully met +- Use `jjtask flag blocked/review/untested` if criteria aren't fully met - Task descriptions are specifications - follow them exactly -- `jj task next` shows specs and available transitions +- @ is always a merge of all WIP + done-with-content tasks ## Task Flags @@ -116,24 +115,24 @@ The `label("task " ++ task_flag, ...)` applies colors defined in jjtask's `[colo ## Commands -All commands work as `jj task <cmd>` (requires alias in config) or `jjtask <cmd>` directly: - | Command | Action | | --- | --- | -| `jj task find [flag]` | List tasks by status | -| `jj task create [parent] <title> [desc]` | Create task revision | -| `jj task flag <rev> <flag>` | Update task status | -| `jj task next [--mark-as flag] [rev]` | Review current task, transition to next | -| `jj task finalize [rev]` | Strip [task:*] for final commit | -| `jj task parallel <parent> <t1> <t2>...` | Create sibling tasks | -| `jj task hoist` | Rebase pending tasks to @- | -| `jj task show-desc [rev]` | Print revision description | -| `jj task checkpoint [name]` | Create named checkpoint | +| `jjtask create <title> [desc]` | Create task revision | +| `jjtask wip [task]` | Mark WIP, rebuild @ as merge | +| `jjtask done [task]` | Mark done (stays in @ if content) | +| `jjtask drop <task>` | Remove from @ (mark standby) | +| `jjtask squash` | Flatten @ merge for push | +| `jjtask find [-s status]` | List tasks by status | +| `jjtask flag <status> [-r rev]` | Update task status | +| `jjtask parallel <t1> <t2>...` | Create sibling tasks | +| `jjtask show-desc [-r rev]` | Print revision description | +| `jjtask checkpoint [name]` | Create named checkpoint | Multi-repo support (requires `.jj-workspaces.yaml`): + | Command | Action | | --- | --- | -| `jj task all <cmd> [args]` | Run jj command across repos | +| `jjtask all <cmd> [args]` | Run jj command across repos | ## Installation @@ -182,7 +181,7 @@ repos: name: backend ``` -Then `jj task find` and `jj task all` operate across all repos. +Then `jjtask find` and `jjtask all` operate across all repos. ## Writing Good Task Descriptions diff --git a/bin/jj b/bin/jj index bddcb73..50c9455 100755 --- a/bin/jj +++ b/bin/jj @@ -115,10 +115,10 @@ case "$1" in fi ;; rebase) - show_hint rebase "AGENT HINT: For hoisting pending tasks to @: jjtask hoist" + show_hint rebase "AGENT HINT: Use 'jjtask wip TASK' to start working (rebuilds @ as merge)" ;; edit) - show_hint edit "AGENT HINT: For task transitions: jjtask next --mark-as STATUS REV" + show_hint edit "AGENT HINT: Use 'jjtask wip TASK' to start, 'jjtask done TASK' to complete" ;; esac diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 2e49404..b07681b 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -43,6 +43,30 @@ } ] } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "jj status --quiet", + "timeout": 5 + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "jj status --quiet", + "timeout": 5 + } + ] + } ] } } diff --git a/claude-plugin/bin/jj b/claude-plugin/bin/jj index bddcb73..50c9455 100755 --- a/claude-plugin/bin/jj +++ b/claude-plugin/bin/jj @@ -115,10 +115,10 @@ case "$1" in fi ;; rebase) - show_hint rebase "AGENT HINT: For hoisting pending tasks to @: jjtask hoist" + show_hint rebase "AGENT HINT: Use 'jjtask wip TASK' to start working (rebuilds @ as merge)" ;; edit) - show_hint edit "AGENT HINT: For task transitions: jjtask next --mark-as STATUS REV" + show_hint edit "AGENT HINT: Use 'jjtask wip TASK' to start, 'jjtask done TASK' to complete" ;; esac diff --git a/claude-plugin/commands/batch-desc.md b/claude-plugin/commands/batch-desc.md index a37f7e2..a9df8cb 100644 --- a/claude-plugin/commands/batch-desc.md +++ b/claude-plugin/commands/batch-desc.md @@ -1,14 +1,16 @@ --- description: Apply sed transformations to multiple revision descriptions -argument-hint: <sed-expr> <revset> +argument-hint: <sed-expr> -r <revset> allowed-tools: - Bash + - AskUserQuestion +model: haiku --- <objective> Transform descriptions of multiple revisions matching a revset. -Example: `jjtask batch-desc 's/old/new/' 'tasks_pending()'` +Example: `jjtask batch-desc 's/old/new/' -r 'tasks_pending()'` </objective> <process> diff --git a/claude-plugin/commands/checkpoint.md b/claude-plugin/commands/checkpoint.md index bca84ce..e4a96de 100644 --- a/claude-plugin/commands/checkpoint.md +++ b/claude-plugin/commands/checkpoint.md @@ -1,13 +1,15 @@ --- description: Create a checkpoint commit -argument-hint: [name] +argument-hint: [-m message] allowed-tools: - Bash +model: haiku --- <objective> Record the current operation ID before risky operations. +Example: `jjtask checkpoint -m "Before risky rebase"` Restore later with: `jj op restore <op-id>` </objective> diff --git a/claude-plugin/commands/create.md b/claude-plugin/commands/create.md index c118320..9cf5b31 100644 --- a/claude-plugin/commands/create.md +++ b/claude-plugin/commands/create.md @@ -1,10 +1,11 @@ --- description: Create a new todo task revision -argument-hint: [parent] <title> [description] +argument-hint: <title> [description] [--parent REV] [--chain] [--draft] allowed-tools: - Skill(jjtask) - Read - Bash + - AskUserQuestion --- <objective> @@ -31,9 +32,11 @@ BEFORE CREATING - you MUST: THEN create: 3. Run: `jjtask create $ARGUMENTS` - - One arg: `jjtask create "title"` (parent = @) - - Two args: `jjtask create parent "title"` - - Three args: `jjtask create parent "title" "description"` + - Basic: `jjtask create "title"` (parent = @) + - With description: `jjtask create "title" "description"` + - Custom parent: `jjtask create --parent xyz "title"` + - Auto-chain: `jjtask create --chain "title"` (chains from deepest pending) + - Draft: `jjtask create --draft "title"` </process> <success_criteria> diff --git a/claude-plugin/commands/desc-transform.md b/claude-plugin/commands/desc-transform.md index c18c849..31921fe 100644 --- a/claude-plugin/commands/desc-transform.md +++ b/claude-plugin/commands/desc-transform.md @@ -1,14 +1,17 @@ --- description: Transform a revision description with sed -argument-hint: <rev> <sed-expr> +argument-hint: <sed-expr> [-r rev] allowed-tools: - Bash + - AskUserQuestion +model: haiku --- <objective> Pipe a revision's description through sed and update it. -Example: `jjtask desc-transform @ 's/foo/bar/'` +Example: `jjtask desc-transform 's/foo/bar/'` (defaults to @) +Example: `jjtask desc-transform 's/foo/bar/' -r xyz` </objective> <process> diff --git a/claude-plugin/commands/done.md b/claude-plugin/commands/done.md new file mode 100644 index 0000000..4a563fc --- /dev/null +++ b/claude-plugin/commands/done.md @@ -0,0 +1,29 @@ +--- +description: Mark task done and linearize into ancestry +argument-hint: [tasks...] +allowed-tools: + - Bash + - AskUserQuestion +model: haiku +--- + +<objective> +Mark tasks as done. Done tasks linearize into ancestry - other WIP parents rebase onto them, creating linear history. + +Part of mega-merge workflow - see `/jjtask` for full context. +</objective> + +<context> +Current WIP tasks: +!`jjtask find wip 2>/dev/null || echo "no wip tasks"` +</context> + +<process> +Run: `jjtask done $ARGUMENTS` + +- No args: marks current task (@) as done +- With tasks: marks those tasks as done +- Multiple: `jjtask done a b c` + +Done tasks become ancestors of remaining WIP tasks. +</process> diff --git a/claude-plugin/commands/drop.md b/claude-plugin/commands/drop.md new file mode 100644 index 0000000..e17de0e --- /dev/null +++ b/claude-plugin/commands/drop.md @@ -0,0 +1,30 @@ +--- +meprintCompactTasksdescription: Remove tasks from @ merge without marking done +argument-hint: <tasks...> [--abandon] +allowed-tools: + - Bash + - AskUserQuestion +model: haiku +--- + +<objective> +Remove tasks from @ parents without marking them done. + +Marks as 'standby' by default so they can be re-added later. +Use --abandon to permanently remove. + +Part of mega-merge workflow - see `/jjtask` for full context. +</objective> + +<context> +Current WIP tasks: +!`jjtask find wip 2>/dev/null || echo "no wip tasks"` +</context> + +<process> +Run: `jjtask drop $ARGUMENTS` + +- `jjtask drop xyz` - mark as standby, remove from @ +- `jjtask drop a b c` - drop multiple tasks +- `jjtask drop --abandon xyz` - abandon task entirely +</process> diff --git a/claude-plugin/commands/find.md b/claude-plugin/commands/find.md index faecd15..b76aed0 100644 --- a/claude-plugin/commands/find.md +++ b/claude-plugin/commands/find.md @@ -1,16 +1,19 @@ --- description: Find revisions with specific task flags -argument-hint: [flag] +argument-hint: [-s status] [-r revset] allowed-tools: - Skill(jjtask) - Bash + - AskUserQuestion +model: haiku --- <objective> -List task revisions filtered by status flag. +List task revisions filtered by status flag or custom revset. Without arguments: shows all pending tasks. -With flag: shows only tasks with that flag (todo, wip, done, blocked, etc.) +With -s: shows tasks with that status (pending, todo, wip, done, blocked, standby, untested, draft, review, all) +With -r: shows tasks matching custom revset Part of `/jjtask` - run that skill for full workflow context. </objective> diff --git a/claude-plugin/commands/flag.md b/claude-plugin/commands/flag.md index 37a114b..b573ab8 100644 --- a/claude-plugin/commands/flag.md +++ b/claude-plugin/commands/flag.md @@ -1,15 +1,22 @@ --- description: Update task flag on a revision -argument-hint: <rev> <flag> +argument-hint: <flag> [-r rev] allowed-tools: - Skill(jjtask) - Bash + - AskUserQuestion +model: haiku --- <objective> Change the task status flag on a revision. -Flags: draft, todo, wip, blocked, standby, untested, review, done +For common transitions, use dedicated commands: +- `jjtask wip TASK` - mark WIP and rebuild @ as merge +- `jjtask done TASK` - mark done (stays in @ if has content) +- `jjtask drop TASK` - remove from @ without completing + +For other flags (draft, blocked, standby, untested, review), use this command. Part of `/jjtask` - run that skill for full workflow context. </objective> diff --git a/claude-plugin/commands/reorganize.md b/claude-plugin/commands/reorganize.md index b6839af..bc7b7a3 100644 --- a/claude-plugin/commands/reorganize.md +++ b/claude-plugin/commands/reorganize.md @@ -4,34 +4,31 @@ allowed-tools: - Skill(jjtask) - Bash - Read + - AskUserQuestion +model: haiku --- <objective> Review the current task DAG structure and suggest improvements: - Identify dependency issues (task mentions another but isn't a child) - Find parallelization opportunities (independent tasks that could run concurrently) -- Detect structural problems (orphaned tasks, blocked children, incomplete drafts) - -Part of `/jjtask` - run that skill for full workflow context. +- Detect structural problems (blocked children, incomplete drafts) </objective> <context> Current task DAG: -!`jjtask find 2>/dev/null || echo "no tasks"` - -Task descriptions (for dependency analysis): -!`for rev in $(jj log -r 'tasks_pending()' -T 'change_id.shortest() ++ "\n"' --no-graph 2>/dev/null | head -10); do echo "=== $rev ==="; jjtask show-desc "$rev" 2>/dev/null | head -20; echo; done` </context> <process> -1. Review the DAG structure above -2. Read task descriptions looking for dependency keywords: "after", "requires", "depends on", "needs", "once X is done" -3. Identify issues: - - Tasks referencing others that aren't ancestors - - Sequential tasks that could be parallel - - Orphaned tasks needing hoist -4. Propose concrete rebase commands for each issue -5. Execute rebases only with user confirmation +1. Run `jjtask find -s all` and `jj log -r 'tasks()` to get DAG structure +2. Log: "Reading task descriptions for dependency keywords..." +3. For each task, read description with `jjtask show-desc -r REV` +4. Log findings as you discover them: + - "Found: mp references lv but isn't a child" + - "Found: ky and pkm overlap - same precompact feature" +5. Present summary of all issues found +6. Propose concrete rebase commands for each issue +7. Execute rebases only with user confirmation, logging each: "Rebased X to Y" </process> <success_criteria> diff --git a/claude-plugin/commands/show-desc.md b/claude-plugin/commands/show-desc.md index 267324b..f8b3563 100644 --- a/claude-plugin/commands/show-desc.md +++ b/claude-plugin/commands/show-desc.md @@ -3,6 +3,8 @@ description: Show revision description argument-hint: [rev] allowed-tools: - Bash + - AskUserQuestion +model: haiku --- <objective> diff --git a/claude-plugin/commands/squash.md b/claude-plugin/commands/squash.md new file mode 100644 index 0000000..610906c --- /dev/null +++ b/claude-plugin/commands/squash.md @@ -0,0 +1,31 @@ +--- +description: Flatten @ merge into linear commit for push +argument-hint: [--keep-tasks] +allowed-tools: + - Bash + - AskUserQuestion +model: haiku +--- + +<objective> +Flatten the current @ merge into a single linear commit, ready for pushing. + +Combines descriptions from all merged tasks. + +Part of mega-merge workflow - see `/jjtask` for full context. +</objective> + +<context> +Current @ state: +!`jj log -r @ --no-graph 2>/dev/null | head -5` + +WIP tasks that will be squashed: +!`jjtask find wip 2>/dev/null || echo "no wip tasks"` +</context> + +<process> +Run: `jjtask squash $ARGUMENTS` + +- `jjtask squash` - flatten everything +- `jjtask squash --keep-tasks` - keep task revisions after squash +</process> diff --git a/claude-plugin/commands/wip.md b/claude-plugin/commands/wip.md new file mode 100644 index 0000000..11fdf17 --- /dev/null +++ b/claude-plugin/commands/wip.md @@ -0,0 +1,31 @@ +--- +description: Mark task WIP and add as parent of @ +argument-hint: [tasks...] +allowed-tools: + - Bash +model: haiku +--- + +<objective> +Mark tasks as WIP and add them as parents of @. Multiple WIP tasks create a merge. + +Part of mega-merge workflow - see `/jjtask` for full context. +</objective> + +<context> +Current WIP tasks: +!`jjtask find wip 2>/dev/null || echo "no wip tasks"` + +Pending tasks: +!`jjtask find todo 2>/dev/null | head -10` +</context> + +<process> +Run: `jjtask wip $ARGUMENTS` + +- No args: marks @ as WIP (if it's a task) +- With tasks: marks those tasks as WIP +- Multiple: `jjtask wip a b c` + +Tasks are added as parents of @ (preserves @ content). +</process> diff --git a/claude-plugin/skills/jj/SKILL.md b/claude-plugin/skills/jj/SKILL.md index f6b5e59..536b66d 100644 --- a/claude-plugin/skills/jj/SKILL.md +++ b/claude-plugin/skills/jj/SKILL.md @@ -164,18 +164,8 @@ jj log -r 'description(substring:"[task:")' # CORRECT jj log -r 'tasks()' # BEST - use alias ``` </pitfall> -</anti_patterns> - -<scripts> -Helper scripts: -| Script | Purpose | -| ----------------------------- | ------------------------------------ | -| `jjtask show-desc [REV]` | Print full description only | -| `jjtask desc-transform REV SED` | Pipe description through command | -| `jjtask batch-desc SED REVSET` | Batch transform descriptions | -| `jjtask checkpoint [NAME]` | Record op ID before risky operations | -</scripts> +</anti_patterns> <recovery> diff --git a/claude-plugin/skills/jjtask/SKILL.md b/claude-plugin/skills/jjtask/SKILL.md index 2ccea72..d0130b3 100644 --- a/claude-plugin/skills/jjtask/SKILL.md +++ b/claude-plugin/skills/jjtask/SKILL.md @@ -14,25 +14,24 @@ Manage a DAG of empty revisions as TODO markers representing tasks. Revision des <quick_start> ```bash -# 1. Plan: Create TODO chain (parent required - ensures clean DAG) -jjtask create @ "Add user validation" "Check email format and password strength" -# Created: abc123 as child of @ - -jjtask create abc123 "Add validation tests" "Test valid/invalid emails and passwords" -# Created: def456 as child of abc123 -> forms chain: @ -> abc123 -> def456 - -# 2. Start working -jj edit abc123 -jjtask flag @ wip - -# ... implement validation ... - -# 3. Review specs and move to next -jjtask next -# Shows current specs and available next tasks - -# 4. Mark done and continue -jjtask next --mark-as done def456 # Marks abc123 done, starts def456 +# 1. Plan: Create TODO tasks +jjtask create "Add user validation" "Check email format and password strength" +jjtask create --chain "Add validation tests" "Test valid/invalid emails and passwords" + +# 2. Start working on a task +jjtask wip abc123 +# Single task: @ becomes the task (jj edit) +# Multiple WIP: @ becomes merge commit + +# 3. Work and complete +# For single task: work directly in @ +# For merge: jj edit TASK to work in specific task +jjtask done abc123 +# Task linearizes into ancestry (becomes ancestor of remaining WIP) + +# 4. Flatten for push +jjtask squash +# Squashes all merged task content into linear commit ``` </quick_start> @@ -41,7 +40,7 @@ jjtask next --mark-as done def456 # Marks abc123 done, starts def456 - Status flags reflect actual task state - DAG shows clear priority (chained tasks) and parallelism (sibling tasks) - All acceptance criteria met before marking done -- No orphaned tasks far from @ +- @ is always a merge of all WIP tasks </success_criteria> <status_flags> @@ -60,9 +59,12 @@ jjtask next --mark-as done def456 # Marks abc123 done, starts def456 Progression: `draft` -> `todo` -> `wip` -> `done` ```bash -jjtask flag @ wip # Start work -jjtask flag @ untested # Implementation done -jjtask flag @ done # Complete +jjtask wip xyz # Mark xyz as WIP, add as parent of @ +jjtask wip a b c # Mark multiple as WIP +jjtask done xyz # Mark done, linearizes into ancestry +jjtask done a b c # Mark multiple as done +jjtask drop xyz # Remove from @ without completing +jjtask flag review # Other flags via generic command ``` </status_flags> @@ -72,21 +74,21 @@ Flag changes only update status. To modify description content: ```bash # Add completion notes when marking done -jjtask flag @ done -jj desc -r @ -m "$(jjtask show-desc @) +jjtask done xyz +jj desc -r xyz -m "$(jjtask show-desc -r xyz) ## Completion - What was done - Deviations from spec" # Check off acceptance criteria -jjtask desc-transform @ 's/- \[ \] First criterion/- [x] First criterion/' +jjtask desc-transform 's/- \[ \] First criterion/- [x] First criterion/' # Append a section -jjtask desc-transform @ 's/$/\n\n## Notes\nAdditional context here/' +jjtask desc-transform 's/$/\n\n## Notes\nAdditional context here/' # Batch update multiple tasks -jjtask batch-desc 's/old-term/new-term/g' 'tasks_todo()' +jjtask batch-desc 's/old-term/new-term/g' -r 'tasks_todo()' ``` When to use what: @@ -100,18 +102,21 @@ When to use what: ```bash jjtask find # Pending tasks with DAG structure -jjtask find todo # Only [task:todo] -jjtask find wip # Only [task:wip] -jjtask find done # Only [task:done] -jjtask find all # All tasks including done +jjtask find -s todo # Only [task:todo] +jjtask find -s wip # Only [task:wip] +jjtask find -s done # Only [task:done] +jjtask find -s all # All tasks including done ``` </finding_tasks> <parallel_tasks> ```bash -# Create parallel branches -jjtask parallel <parent-id> "Widget A" "Widget B" "Widget C" +# Create parallel branches from @ (default parent) +jjtask parallel "Widget A" "Widget B" "Widget C" + +# Or specify parent explicitly +jjtask parallel --parent xyz123 "Widget A" "Widget B" # Merge point (all parents must complete) jj new --no-edit <A-id> <B-id> <C-id> -m "[task:todo] Integration\n\n..." @@ -210,50 +215,59 @@ Parallelization opportunities: - Independent features under same parent -> good candidates for parallel agents Structural issues: -- Orphaned tasks far from @ -> `jjtask hoist` or manual rebase - Done tasks with pending children -> children may be blocked - Draft tasks mixed with todo -> drafts need specs before work begins </dag_validation> -<hoisting> +<working_in_merge> + +When @ is a merge of multiple WIP tasks: -When you make commits, tasks created earlier stay behind. Run `jjtask hoist` to move pending tasks up: +**Recommended: Work directly in task branch** +```bash +jj edit task-a # Switch to working in the task +# make changes... +jjtask wip task-a # Rebuild merge to see combined state +``` +**Alternative: Use absorb with explicit targets** ```bash -jjtask hoist -# Moves pending task roots to be children of @ +jj absorb --into 'tasks_wip()' # Only route to WIP tasks ``` -</hoisting> -<finalizing> +**Avoid bare `jj absorb`** - it may route changes to ancestor commits if you're editing lines not touched by your task branches. +</working_in_merge> + +<squashing> + +After tasks are complete, flatten the merge for a clean push: ```bash -# Strip [task:done] prefix for final commit -jjtask finalize @ +jjtask squash +# Combines all merged task content into a single linear commit +# Task descriptions become bullet points in commit message ``` -</finalizing> +</squashing> <commands> -| Command | Purpose | -| ----------------------------------- | --------------------------------- | -| `jjtask create PARENT TITLE [DESC]` | Create TODO as child of PARENT | -| `jjtask parallel PARENT T1 T2...` | Create parallel TODOs | -| `jjtask next [--mark-as STATUS] [REV]` | Review specs, optionally move | -| `jjtask flag REV FLAG` | Update status flag | -| `jjtask find [FLAG] [-r REVSET]` | Find tasks (flags or custom revset)| -| `jjtask hoist` | Rebase pending tasks to @ | -| `jjtask finalize [REV]` | Strip task prefix for final commit| -| `jjtask show-desc [REV]` | Print revision description | -| `jjtask desc-transform REV SED` | Transform description with sed | -| `jjtask batch-desc SED REVSET` | Transform multiple descriptions | -| `jjtask checkpoint [NAME]` | Create named checkpoint | -| `jjtask all <cmd> [args]` | Run jj command across all repos | -| `jjtask prime` | Output session context for hooks | -| `jjtask parallel-start [--mode] TASK` | Start parallel agent session | -| `jjtask parallel-stop [TASK]` | Stop parallel session | -| `jjtask parallel-status [TASK]` | Show parallel session status | -| `jjtask agent-context ID` | Get context for parallel agent | +| Command | Purpose | +| ---------------------------------------- | ---------------------------------- | +| `jjtask create TITLE [-p REV] [--chain]` | Create TODO (direct child of @) | +| `jjtask wip [TASKS...]` | Mark WIP, add as parents of @ | +| `jjtask done [TASKS...]` | Mark done, linearize into ancestry | +| `jjtask drop TASKS... [--abandon]` | Remove from @ (standby or abandon) | +| `jjtask squash` | Flatten @ merge for push | +| `jjtask parallel T1 T2... [-p REV]` | Create parallel TODOs | +| `jjtask flag STATUS [-r REV]` | Update status flag (defaults to @) | +| `jjtask find [-s STATUS] [-r REVSET]` | Find tasks by status or revset | +| `jjtask show-desc [-r REV]` | Print revision description | +| `jjtask desc-transform CMD [-r REV]` | Transform description with command | +| `jjtask batch-desc EXPR -r REVSET` | Transform multiple descriptions | +| `jjtask checkpoint [-m MSG]` | Create named checkpoint | +| `jjtask stale` | Find done tasks not in @'s ancestry| +| `jjtask all <cmd> [args]` | Run jj command across all repos | +| `jjtask prime` | Output session context for hooks | </commands> <multi_repo> @@ -273,40 +287,31 @@ Scripts show output grouped by repo. Use `jjtask all log` or `jjtask all diff` a <parallel_agents> -Multiple Claude agents can work simultaneously on the same repo. - -Detecting parallel context - check `jjtask prime` output for "Parallel Session Active" section, or run: -```bash -jjtask agent-context <your-agent-id> -``` - -Rules for parallel work: -1. ONLY modify files in your assignment - other files belong to other agents -2. Check assignments: `jjtask parallel-status` -3. Mark your task done when complete: `jjtask flag @ done` - -Shared mode (agents share @): -- Your changes appear immediately to others -- File discipline critical - stay in your assigned patterns -- Conflicts possible if patterns overlap - -Workspace mode (isolated directories): -- You have your own working copy in `.jjtask-workspaces/<agent>/` -- Other agents can't affect your files -- Complete isolation until session ends +Multiple Claude agents can work simultaneously using jj workspaces: -Commands: ```bash -jjtask agent-context <id> # Your assignment and context -jjtask parallel-status # All agents' progress -jjtask parallel-recover # Fix workspace issues +# Terminal 1: Agent working on task A +jj workspace add .workspaces/agent-a --revision task-a +cd .workspaces/agent-a +# work... +jjtask done # Rebuilds this workspace's @ + +# Terminal 2: Agent working on task B +jj workspace add .workspaces/agent-b --revision task-b +cd .workspaces/agent-b +# work... +jjtask done # Rebuilds this workspace's @ + +# Cleanup when done +jj workspace forget agent-a +rm -rf .workspaces/agent-a ``` -See `references/parallel-agents.md` for full documentation. +Each workspace has its own @ that mega-merge rebuilds independently. +No special coordination needed - jj handles workspace isolation. </parallel_agents> <references> -- `references/parallel-agents.md` - Multi-agent parallel execution - `references/batch-operations.md` - Batch description transformations - `references/command-syntax.md` - JJ command flag details </references> diff --git a/claude-plugin/skills/jjtask/references/batch-operations.md b/claude-plugin/skills/jjtask/references/batch-operations.md index dc49d13..cdc4879 100644 --- a/claude-plugin/skills/jjtask/references/batch-operations.md +++ b/claude-plugin/skills/jjtask/references/batch-operations.md @@ -48,7 +48,7 @@ done The `jjtask batch-desc` helper simplifies this: ```bash -jjtask batch-desc 's/old/new/' 'tasks_pending()' +jjtask batch-desc 's/old/new/' -r 'tasks_pending()' ``` </jjtask_helper> diff --git a/cmd/jjtask/cmd/agent_context.go b/cmd/jjtask/cmd/agent_context.go deleted file mode 100644 index b2f34cf..0000000 --- a/cmd/jjtask/cmd/agent_context.go +++ /dev/null @@ -1,208 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - - "jjtask/internal/parallel" -) - -var agentContextFormat string - -var agentContextCmd = &cobra.Command{ - Use: "agent-context <agent-id>", - Short: "Get context for a parallel agent", - Long: `Output context information for an agent in a parallel session. - -Shows the agent's assignment, files to avoid (other agents), and DAG state. - -Examples: - jjtask agent-context agent-a - jjtask agent-context agent-b --format json`, - Args: cobra.ExactArgs(1), - RunE: runAgentContext, -} - -func init() { - agentContextCmd.Flags().StringVar(&agentContextFormat, "format", "text", "Output format: text or json") - rootCmd.AddCommand(agentContextCmd) -} - -type AgentContextOutput struct { - AgentID string `json:"agent_id"` - Mode string `json:"mode"` - Workspace string `json:"workspace,omitempty"` - TaskID string `json:"task_id,omitempty"` - Assignment string `json:"assignment"` - AvoidFiles []string `json:"avoid_files"` - OtherAgents []string `json:"other_agents"` - ParentTask string `json:"parent_task"` - ParentTaskID string `json:"parent_task_id"` -} - -func runAgentContext(cmd *cobra.Command, args []string) error { - agentID := args[0] - - // Find parent task with parallel session - session, parentRev, parentDesc, err := findParallelSession() - if err != nil { - return err - } - if session == nil { - return fmt.Errorf("no parallel session found; start one with: jjtask parallel-start") - } - - // Find this agent - agent := session.GetAgentByID(agentID) - if agent == nil { - var available []string - for _, a := range session.Agents { - available = append(available, a.ID) - } - return fmt.Errorf("agent %q not in session; available: %s", agentID, strings.Join(available, ", ")) - } - - // Build output - output := AgentContextOutput{ - AgentID: agentID, - Mode: session.Mode, - Assignment: agent.FilePattern, - ParentTaskID: parentRev, - } - - // Extract parent title - lines := strings.Split(parentDesc, "\n") - if len(lines) > 0 { - output.ParentTask = strings.TrimSpace(lines[0]) - } - - // Workspace path - if session.Mode == "workspace" && agent.TaskID != "" { - output.TaskID = agent.TaskID - repoRoot, err := client.Root() - if err != nil { - return fmt.Errorf("get repo root: %w", err) - } - output.Workspace = filepath.Join(repoRoot, parallel.WorkspacesDir, agentID) - } - - // Other agents' assignments - for _, other := range session.OtherAgents(agentID) { - output.OtherAgents = append(output.OtherAgents, other.ID) - if other.FilePattern != "" { - output.AvoidFiles = append(output.AvoidFiles, fmt.Sprintf("%s (%s)", other.FilePattern, other.ID)) - } - } - - if agentContextFormat == "json" { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(output) - } - - // Text output - printAgentContext(output, session, parentRev) - return nil -} - -func printAgentContext(ctx AgentContextOutput, _ *parallel.Session, parentRev string) { - fmt.Printf("## Agent Context: %s\n\n", ctx.AgentID) - fmt.Printf("Mode: %s\n", ctx.Mode) - - if ctx.Workspace != "" { - fmt.Printf("Workspace: %s\n", ctx.Workspace) - } - if ctx.TaskID != "" { - fmt.Printf("Task: %s\n", ctx.TaskID) - } - - fmt.Println() - - if ctx.Assignment != "" { - fmt.Println("### Your Assignment") - fmt.Println(ctx.Assignment) - fmt.Println() - } - - if len(ctx.AvoidFiles) > 0 { - fmt.Println("### Files to AVOID (other agents)") - for _, f := range ctx.AvoidFiles { - fmt.Printf("- %s\n", f) - } - fmt.Println() - } - - fmt.Println("### Parent Task") - fmt.Printf("%s %s\n", parentRev, ctx.ParentTask) - fmt.Println() - - // Show DAG - dagOutput, err := client.Query("log", "-r", fmt.Sprintf("(%s):: & tasks()", parentRev), "-T", ` -separate(" ", - if(self.contained_in("@"), "←", ""), - change_id.shortest(), - description.first_line().substr(0,60) -) ++ "\n" -`) - if err == nil && strings.TrimSpace(dagOutput) != "" { - fmt.Println("### DAG State") - fmt.Println(dagOutput) - } -} - -func findParallelSession() (session *parallel.Session, parentRev, parentDesc string, err error) { - // Strategy 1: look at @ and its ancestors for a parallel session - revsToCheck := []string{"@", "@-", "@--"} - - // Also check if we're in a workspace subdirectory - cwd, _ := os.Getwd() - if strings.Contains(cwd, parallel.WorkspacesDir) { - // In a workspace, check the parent workspace's tasks - revsToCheck = append([]string{"@-"}, revsToCheck...) - } - - for _, rev := range revsToCheck { - desc, err := client.GetDescription(rev) - if err != nil { - continue - } - - session, parseErr := parallel.ParseSession(desc) - if parseErr != nil { - continue - } - if session != nil && len(session.Agents) > 0 { - // Get the actual change ID - revID, _ := client.Query("log", "-r", rev, "--no-graph", "-T", "change_id.shortest()") - return session, strings.TrimSpace(revID), desc, nil - } - } - - // Strategy 2: look for any task with "## Parallel Session" in description - taskRevs, err := client.Query("log", "-r", "tasks()", "--no-graph", "-T", "change_id.shortest() ++ \"\\n\"") - if err == nil { - for _, rev := range strings.Split(strings.TrimSpace(taskRevs), "\n") { - if rev == "" { - continue - } - desc, err := client.GetDescription(rev) - if err != nil { - continue - } - session, parseErr := parallel.ParseSession(desc) - if parseErr != nil { - continue - } - if session != nil && len(session.Agents) > 0 { - return session, rev, desc, nil - } - } - } - - return nil, "", "", nil -} diff --git a/cmd/jjtask/cmd/basic_test.go b/cmd/jjtask/cmd/basic_test.go new file mode 100644 index 0000000..0febba8 --- /dev/null +++ b/cmd/jjtask/cmd/basic_test.go @@ -0,0 +1,187 @@ +package cmd_test + +import ( + "strings" + "testing" +) + +func TestCreateTask(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Test task", "Test description") + + output := repo.Run("jjtask", "find") + if !strings.Contains(output, "Test task") { + t.Error("task not found in output") + } + +} + +func TestCreateDraft(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "--draft", "@", "Draft task") + + output := repo.Run("jjtask", "find", "--status", "all") + if !strings.Contains(output, "[task:draft]") { + t.Error("draft flag not found") + } + +} + +func TestFlagUpdatesStatus(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Test task") + taskID := repo.GetTaskID("todo") + repo.Run("jjtask", "flag", "wip", "--rev", taskID) + + output := repo.Run("jjtask", "find") + if !strings.Contains(output, "[task:wip]") { + t.Error("wip flag not found") + } + +} + +func TestFindShowsTasks(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task A") + repo.Run("jjtask", "create", "Task B") + + output := repo.Run("jjtask", "find") + if !strings.Contains(output, "Task A") || !strings.Contains(output, "Task B") { + t.Error("tasks not found in output") + } + +} + +func TestFindRevsetFiltersTasks(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "My task") + repo.Run("jj", "new", "-m", "Regular commit") + + output := repo.Run("jjtask", "find", "-r", "all()") + if !strings.Contains(output, "My task") { + t.Error("task not found in output") + } + if strings.Contains(output, "Regular commit") { + t.Error("regular commit should not appear in task find") + } + +} + +func TestShowDesc(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Test title", "Test body content") + taskID := repo.GetTaskID("todo") + repo.Run("jj", "edit", taskID) + + output := repo.Run("jjtask", "show-desc") + if !strings.Contains(output, "Test title") || !strings.Contains(output, "Test body content") { + t.Error("description content not found") + } + +} + +func TestParallelCreatesSiblings(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "parallel", "Task A", "Task B", "Task C") + + output := repo.Run("jjtask", "find") + if !strings.Contains(output, "Task A") || + !strings.Contains(output, "Task B") || + !strings.Contains(output, "Task C") { + t.Error("parallel tasks not found") + } + +} + +func TestPrime(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + output := repo.Run("jjtask", "prime") + if output == "" { + t.Error("prime produced no output") + } + +} + +func TestCheckpoint(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + output := repo.Run("jjtask", "checkpoint", "-m", "test-checkpoint") + if output == "" { + t.Error("checkpoint produced no output") + } + +} + +func TestDescTransform(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Original title", "## Context\nSome context here") + taskID := repo.GetTaskID("todo") + repo.Run("jjtask", "desc-transform", "--rev", taskID, "sed", "s/Original/Modified/") + + output := repo.Run("jjtask", "show-desc", "--rev", taskID) + if !strings.Contains(output, "Modified title") { + t.Error("transform not applied") + } + +} + +func TestDescTransformError(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Test title") + taskID := repo.GetTaskID("todo") + + output := repo.RunExpectFail("jjtask", "desc-transform", "--rev", taskID, "nonexistent-cmd-xyz") + if output == "" { + t.Error("expected error output") + } + +} + +func TestConfigTaskLogDiffStats(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("testfile.txt", "test content") + repo.Run("jj", "describe", "-m", "Test commit with changes") + + output := repo.Run("jj", "log", "-r", "@", "--no-graph", "-T", "task_log") + if output == "" { + t.Error("task_log template produced no output") + } + +} + +func TestConfigTaskLogShortDesc(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Short title", "## Context\nThis is a longer description\nwith multiple lines") + taskID := repo.GetTaskID("todo") + + output := repo.Run("jj", "log", "-r", taskID, "--no-graph", "-T", "task_log") + if !strings.Contains(output, "Short title") { + t.Error("title not found in task_log output") + } + +} diff --git a/cmd/jjtask/cmd/batch_desc.go b/cmd/jjtask/cmd/batch_desc.go index b439d4e..4a51013 100644 --- a/cmd/jjtask/cmd/batch_desc.go +++ b/cmd/jjtask/cmd/batch_desc.go @@ -9,18 +9,24 @@ import ( "github.com/spf13/cobra" ) +var batchDescRevset string + var batchDescCmd = &cobra.Command{ - Use: "batch-desc <sed-expr> <revset>", + Use: "batch-desc <sed-expr> --revset REVSET", Short: "Transform multiple revision descriptions", Long: `Apply a sed transformation to all revisions matching a revset. Examples: - jjtask batch-desc 's/old/new/' 'tasks_todo()' - jjtask batch-desc 's/WIP/DONE/' 'description(substring:"WIP")'`, - Args: cobra.ExactArgs(2), + jjtask batch-desc 's/old/new/' --revset 'tasks_todo()' + jjtask batch-desc 's/WIP/DONE/' -r 'description(substring:"WIP")'`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { sedExpr := args[0] - revset := args[1] + revset := batchDescRevset + + if revset == "" { + return fmt.Errorf("--revset is required") + } // Get matching revisions out, err := client.Query("log", "-r", revset, "--no-graph", "-T", "change_id.shortest() ++ \"\\n\"") @@ -80,22 +86,15 @@ Examples: func init() { rootCmd.AddCommand(batchDescCmd) - // Second argument is a revset, which could be a revision or revset expression - batchDescCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) >= 2 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - if len(args) == 1 { - // Second arg is revset - offer common revset aliases - return []string{ - "tasks()", - "tasks_pending()", - "tasks_todo()", - "tasks_wip()", - "tasks_done()", - }, cobra.ShellCompDirectiveNoFileComp - } - // First arg is sed expression - no completion - return nil, cobra.ShellCompDirectiveNoFileComp - } + batchDescCmd.Flags().StringVarP(&batchDescRevset, "revset", "r", "", "revset to match revisions (required)") + _ = batchDescCmd.MarkFlagRequired("revset") + _ = batchDescCmd.RegisterFlagCompletionFunc("revset", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{ + "tasks()", + "tasks_pending()", + "tasks_todo()", + "tasks_wip()", + "tasks_done()", + }, cobra.ShellCompDirectiveNoFileComp + }) } diff --git a/cmd/jjtask/cmd/checkpoint.go b/cmd/jjtask/cmd/checkpoint.go index bcc7f7c..3e73c3d 100644 --- a/cmd/jjtask/cmd/checkpoint.go +++ b/cmd/jjtask/cmd/checkpoint.go @@ -7,21 +7,20 @@ import ( "github.com/spf13/cobra" ) +var checkpointMessage string + var checkpointCmd = &cobra.Command{ - Use: "checkpoint [message]", + Use: "checkpoint [--message MSG]", Short: "Record operation ID for recovery", Long: `Record the current jj operation ID so you can restore to this point if something goes wrong. Examples: jjtask checkpoint - jjtask checkpoint "Before risky rebase"`, - Args: cobra.MaximumNArgs(1), + jjtask checkpoint -m "Before risky rebase"`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - message := "" - if len(args) == 1 { - message = args[0] - } + message := checkpointMessage // Get current operation ID opID, err := client.Query("op", "log", "--no-graph", "-T", "id.short()", "--limit", "1") @@ -49,5 +48,6 @@ Examples: } func init() { + checkpointCmd.Flags().StringVarP(&checkpointMessage, "message", "m", "", "checkpoint message") rootCmd.AddCommand(checkpointCmd) } diff --git a/cmd/jjtask/cmd/completion.go b/cmd/jjtask/cmd/completion.go index ed71efb..ac3dacb 100644 --- a/cmd/jjtask/cmd/completion.go +++ b/cmd/jjtask/cmd/completion.go @@ -73,7 +73,7 @@ func completeRevision(cmd *cobra.Command, args []string, toComplete string) ([]s } var completions []string - for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") { if line == "" { continue } @@ -100,7 +100,7 @@ func completeTaskRevision(cmd *cobra.Command, args []string, toComplete string) } var completions []string - for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") { if line == "" { continue } @@ -116,7 +116,7 @@ func completeTaskRevision(cmd *cobra.Command, args []string, toComplete string) } // completeTaskFlag provides completion for task flag values -func completeTaskFlag(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { +func completeTaskFlag(_cmd *cobra.Command, _args []string, _toComplete string) ([]string, cobra.ShellCompDirective) { flags := []string{ "draft\tPlaceholder, needs specification", "todo\tReady to work", diff --git a/cmd/jjtask/cmd/completion_jj.go b/cmd/jjtask/cmd/completion_jj.go index 2cc13cc..03201d4 100644 --- a/cmd/jjtask/cmd/completion_jj.go +++ b/cmd/jjtask/cmd/completion_jj.go @@ -127,10 +127,7 @@ complete -c jj -n "__fish_jj_needs_command" -f -a "task" -d 'Task management (jj complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "find" -d 'List tasks' complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "create" -d 'Create a new task' complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "flag" -d 'Update task status' -complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "next" -d 'Review/transition tasks' -complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "finalize" -d 'Strip task prefix' complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "parallel" -d 'Create sibling tasks' -complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "hoist" -d 'Rebase pending tasks' complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "show-desc" -d 'Print description' complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "desc-transform" -d 'Transform description' complete -c jj -n "__fish_jj_using_task; and __fish_jj_task_needs_subcommand" -f -a "batch-desc" -d 'Transform multiple' @@ -160,13 +157,6 @@ complete -c jj -n "__fish_jj_task_using_subcommand flag" -f -a "untested" -d 'Ne complete -c jj -n "__fish_jj_task_using_subcommand flag" -f -a "review" -d 'Needs review' complete -c jj -n "__fish_jj_task_using_subcommand flag" -f -a "done" -d 'Complete' -# next: --mark-as flag and revision -complete -c jj -n "__fish_jj_task_using_subcommand next" -l mark-as -f -a "draft todo wip blocked standby untested review done" -d 'Mark with status' -complete -c jj -n "__fish_jj_task_using_subcommand next" -f -a "(__fish_jjtask_complete_task_revisions)" - -# finalize: task revision -complete -c jj -n "__fish_jj_task_using_subcommand finalize" -f -a "(__fish_jjtask_complete_task_revisions)" - # show-desc: any revision complete -c jj -n "__fish_jj_task_using_subcommand show-desc" -f -a "(__fish_jjtask_complete_revisions)" diff --git a/cmd/jjtask/cmd/create.go b/cmd/jjtask/cmd/create.go index 92c7f3a..ba41bbe 100644 --- a/cmd/jjtask/cmd/create.go +++ b/cmd/jjtask/cmd/create.go @@ -7,65 +7,158 @@ import ( "github.com/spf13/cobra" ) -var createDraft bool +var ( + createDraft bool + createParent string + createChain bool +) var createCmd = &cobra.Command{ - Use: "create <parent> <title> [description]", + Use: "create <title> [description]", Short: "Create a new task revision", - Long: `Create a new task revision as a child of parent. + Long: `Create a new task revision as direct child of @ (or --parent REV). -Parent is required to ensure tasks form a proper DAG. -Use @ for current revision, or a task ID to chain tasks. +By default, creates a direct child of @. Use --chain to auto-chain from +the deepest pending descendant instead. Examples: - jjtask create @ "Fix bug" - jjtask create @ "Fix bug" "## Context\nDetails here" - jjtask create --draft @ "Future work" - jjtask create mxyz "Subtask" "Chains after task mxyz"`, - Args: cobra.RangeArgs(2, 3), - RunE: func(cmd *cobra.Command, args []string) error { - var parent, title, desc string - - parent = args[0] - title = args[1] - if len(args) == 3 { - desc = args[2] - } + jjtask create "Fix bug" # direct child of @ + jjtask create --parent xyz "Fix bug" # direct child of xyz + jjtask create --chain "Next step" # chain from deepest pending + jjtask create --draft "Future work" # draft task`, + Args: cobra.RangeArgs(1, 2), + RunE: runCreate, +} - flag := "todo" - if createDraft { - flag = "draft" - } +func init() { + createCmd.Flags().BoolVar(&createDraft, "draft", false, "Create with [task:draft] flag") + createCmd.Flags().StringVarP(&createParent, "parent", "p", "", "Create as child of REV (default: @)") + createCmd.Flags().BoolVar(&createChain, "chain", false, "Auto-chain from deepest pending descendant") + rootCmd.AddCommand(createCmd) + createCmd.ValidArgsFunction = completeRevision +} + +func runCreate(cmd *cobra.Command, args []string) error { + title := args[0] + desc := "" + if len(args) >= 2 { + desc = args[1] + } + + parent := "@" + if createParent != "" { + parent = createParent + } + + // Check if @ is a WIP task when using explicit parent (not @) + if parent != "@" { + checkWipSuggestion(cmd) + } - message := fmt.Sprintf("[task:%s] %s", flag, title) - if desc != "" { - message = message + "\n\n" + desc + // Auto-chain: find deepest pending descendant (only with --chain flag) + if createChain { + leaf := findDeepestPendingDescendant(parent) + if leaf != "" { + parent = leaf } + } - err := client.Run("new", "--no-edit", parent, "-m", message) - if err != nil { - return err + flag := "todo" + if createDraft { + flag = "draft" + } + + message := fmt.Sprintf("[task:%s] %s", flag, title) + if desc != "" { + message = message + "\n\n" + desc + } + + err := client.Run("new", "--no-edit", parent, "-m", message) + if err != nil { + return err + } + + // Get the created revision's change ID + out, err := client.Query("log", "-r", "children("+parent+") & description(substring:\"[task:\") & heads(all())", "--no-graph", "-T", "change_id.shortest()", "--limit", "1") + if err != nil { + fmt.Printf("Created task [task:%s] %s (could not resolve ID: %v)\n", flag, title, err) + return nil + } + changeID := strings.TrimSpace(out) + if changeID == "" { + fmt.Printf("Created task [task:%s] %s\n", flag, title) + } else { + fmt.Printf("Created new commit %s (empty) [task:%s] %s\n", changeID, flag, title) + } + + return nil +} + +// findDeepestPendingDescendant finds the deepest pending task descendant of rev +func findDeepestPendingDescendant(rev string) string { + // Get all pending descendants, sorted by depth (most ancestors = deepest) + // We want the leaf of the chain - a task with no pending children + out, err := client.Query("log", + "-r", fmt.Sprintf("(%s | descendants(%s)) & tasks_pending()", rev, rev), + "--no-graph", + "-T", `change_id.shortest() ++ "\n"`, + ) + if err != nil { + return "" + } + + candidates := strings.Split(strings.TrimSpace(out), "\n") + if len(candidates) == 0 || (len(candidates) == 1 && candidates[0] == "") { + return "" + } + + // Find the leaf - a candidate with no pending children + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue } - // Get the created revision's change ID - out, err := client.Query("log", "-r", "children("+parent+") & description(substring:\"[task:\") & heads(all())", "--no-graph", "-T", "change_id.shortest()", "--limit", "1") + // Check if this candidate has any pending children + children, err := client.Query("log", + "-r", fmt.Sprintf("children(%s) & tasks_pending()", candidate), + "--no-graph", + "-T", "change_id.shortest()", + "--limit", "1", + ) if err != nil { - fmt.Printf("Created task [task:%s] %s (could not resolve ID: %v)\n", flag, title, err) - return nil + continue } - changeID := strings.TrimSpace(out) - if changeID == "" { - fmt.Printf("Created task [task:%s] %s\n", flag, title) - } else { - fmt.Printf("Created new commit %s (empty) [task:%s] %s\n", changeID, flag, title) + + if strings.TrimSpace(children) == "" { + // No pending children - this is the leaf + return candidate } + } - return nil - }, + // No leaf found, return last candidate + return strings.TrimSpace(candidates[len(candidates)-1]) } -func init() { - createCmd.Flags().BoolVar(&createDraft, "draft", false, "Create with [task:draft] flag") - rootCmd.AddCommand(createCmd) - createCmd.ValidArgsFunction = completeRevision +// checkWipSuggestion suggests chaining to @ if @ is a WIP task +func checkWipSuggestion(cmd *cobra.Command) { + atDesc, err := client.GetDescription("@") + if err != nil { + return + } + if !strings.HasPrefix(atDesc, "[task:wip]") { + return + } + + atID, err := client.Query("log", "-r", "@", "--no-graph", "-T", "change_id.shortest()") + if err != nil { + return + } + atID = strings.TrimSpace(atID) + + stderr := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(stderr) + _, _ = fmt.Fprintf(stderr, "Note: Current revision (%s) is a WIP task.\n", atID) + _, _ = fmt.Fprintln(stderr, "Consider: `jjtask create \"title\"` to auto-chain from @") + _, _ = fmt.Fprintln(stderr) } diff --git a/cmd/jjtask/cmd/create_test.go b/cmd/jjtask/cmd/create_test.go new file mode 100644 index 0000000..25ec5ef --- /dev/null +++ b/cmd/jjtask/cmd/create_test.go @@ -0,0 +1,138 @@ +package cmd_test + +import ( + "strings" + "testing" +) + +func TestCreateSuggestsChainingWhenWip(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "WIP task") + wipID := repo.GetTaskID("todo") + repo.Run("jjtask", "flag", "wip", "--rev", wipID) + repo.Run("jj", "edit", wipID) + + // Create task with different parent + output := repo.Run("jjtask", "create", "--parent", "root()", "Other task") + + if !strings.Contains(output, "is a WIP task") { + t.Error("expected WIP suggestion") + } + +} + +func TestCreateNoSuggestionNotWip(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + output := repo.Run("jjtask", "create", "New task") + + if strings.Contains(output, "is a WIP task") { + t.Error("should not suggest when @ not WIP") + } + +} + +func TestCreateNoSuggestionParentAt(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "WIP task") + wipID := repo.GetTaskID("todo") + repo.Run("jjtask", "flag", "wip", "--rev", wipID) + repo.Run("jj", "edit", wipID) + + output := repo.Run("jjtask", "create", "Child of wip") + + // Should NOT suggest since we're already using @ as parent + if strings.Contains(output, "is a WIP task") { + t.Error("should not suggest when parent is @") + } + +} + +func TestCreateChainAt(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "--chain", "Task 1") + repo.Run("jjtask", "create", "--chain", "Task 2") + repo.Run("jjtask", "create", "--chain", "Task 3") + + // Verify Task 3 is descendant of Task 1 (chained, not sibling) + task3Parent := repo.Run("jj", "log", + "-r", "description(substring:\"Task 3\") & tasks()", + "--no-graph", "-T", "parents.map(|p| p.description().first_line()).join(\",\")") + + if !strings.Contains(task3Parent, "Task 2") { + t.Error("Task 3 should have Task 2 as parent") + } + +} + +func TestCreateChainParent(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent") + parentID := repo.GetTaskID("todo") + + repo.Run("jjtask", "create", "--chain", "--parent", parentID, "Child 1") + repo.Run("jjtask", "create", "--chain", "--parent", parentID, "Child 2") + + // Child 2 should be child of Child 1, not sibling + child2Parent := repo.Run("jj", "log", + "-r", "description(substring:\"Child 2\") & tasks()", + "--no-graph", "-T", "parents.map(|p| p.description().first_line()).join(\",\")") + + if !strings.Contains(child2Parent, "Child 1") { + t.Error("Child 2 should have Child 1 as parent") + } + +} + +func TestCreateDefaultDirect(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent") + parentID := repo.GetTaskID("todo") + + repo.Run("jjtask", "create", "--parent", parentID, "Child 1") + repo.Run("jjtask", "create", "--parent", parentID, "Child 2") + + // Child 2 should be sibling of Child 1 (both direct children of parent) + child2Parent := repo.Run("jj", "log", + "-r", "description(substring:\"Child 2\") & tasks()", + "--no-graph", "-T", "parents.map(|p| p.description().first_line()).join(\",\")") + + if !strings.Contains(child2Parent, "Parent") { + t.Error("Child 2 should have Parent as parent") + } + + // Verify they're siblings + siblingCount := strings.Count( + repo.Run("jj", "log", "-r", "children("+parentID+") & tasks()", "--no-graph"), + "[task:") + + if siblingCount < 2 { + t.Error("expected 2+ siblings") + } + +} + +func TestCreateWipSuggestionSnapshot(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "WIP task") + wipID := repo.GetTaskID("todo") + repo.Run("jjtask", "flag", "wip", "--rev", wipID) + repo.Run("jj", "edit", wipID) + + // Create task with different parent - capture suggestion message format + repo.Run("jjtask", "create", "--parent", "root()", "Other task") + +} diff --git a/cmd/jjtask/cmd/desc_transform.go b/cmd/jjtask/cmd/desc_transform.go index c8406a0..cc1ddd8 100644 --- a/cmd/jjtask/cmd/desc_transform.go +++ b/cmd/jjtask/cmd/desc_transform.go @@ -9,8 +9,10 @@ import ( "github.com/spf13/cobra" ) +var descTransformRev string + var descTransformCmd = &cobra.Command{ - Use: "desc-transform <rev> <sed-expr|command...>", + Use: "desc-transform <sed-expr|command...> [--rev REV]", Short: "Transform revision description", Long: `Transform a revision description through a command. @@ -18,13 +20,13 @@ If a single argument starting with 's/' is provided, sed is assumed. Otherwise, the command and arguments are executed directly. Examples: - jjtask desc-transform @ 's/foo/bar/' - jjtask desc-transform @ sed 's/foo/bar/' - jjtask desc-transform mxyz awk '/^##/{print}'`, - Args: cobra.MinimumNArgs(2), + jjtask desc-transform 's/foo/bar/' + jjtask desc-transform sed 's/foo/bar/' --rev mxyz + jjtask desc-transform awk '/^##/{print}'`, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - rev := args[0] - cmdArgs := args[1:] + rev := descTransformRev + cmdArgs := args // If single argument starting with s/, assume sed if len(cmdArgs) == 1 && strings.HasPrefix(cmdArgs[0], "s/") { @@ -69,5 +71,6 @@ Examples: func init() { rootCmd.AddCommand(descTransformCmd) - descTransformCmd.ValidArgsFunction = completeRevision + descTransformCmd.Flags().StringVarP(&descTransformRev, "rev", "r", "@", "revision to transform") + _ = descTransformCmd.RegisterFlagCompletionFunc("rev", completeRevision) } diff --git a/cmd/jjtask/cmd/done.go b/cmd/jjtask/cmd/done.go new file mode 100644 index 0000000..ca827fd --- /dev/null +++ b/cmd/jjtask/cmd/done.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var doneCmd = &cobra.Command{ + Use: "done [tasks...]", + Short: "Mark tasks done and linearize into ancestry", + Long: `Mark tasks as done. When task is a parent of @, linearizes it into the ancestry. + +If the task is a merge parent, other parents are rebased onto the done task, +making it part of the linear history rather than a floating branch. + +Examples: + jjtask done xyz # Mark xyz as done + jjtask done # Mark @ as done (if it's a task) + jjtask done a b c # Mark multiple tasks done`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + revs := args + if len(revs) == 0 { + revs = []string{"@"} + } + + var orphans []string + for _, rev := range revs { + isOrphan, err := markDone(cmd, rev) + if err != nil { + return fmt.Errorf("failed to mark %s done: %w", rev, err) + } + if isOrphan { + changeID, _ := client.Query("log", "-r", rev, "--no-graph", "-T", "change_id.shortest()") + orphans = append(orphans, strings.TrimSpace(changeID)) + } + } + + if len(orphans) > 0 { + printOrphanWarning(orphans) + } + + return nil + }, +} + +// markDone marks a task as done and linearizes if it's a merge parent. +// Returns true if the task is an orphan (not in @'s ancestry after marking done). +func markDone(cmd *cobra.Command, rev string) (isOrphan bool, err error) { + // Get change ID + changeID, err := client.Query("log", "-r", rev, "--no-graph", "-T", "change_id.shortest()") + if err != nil { + return false, fmt.Errorf("getting change ID: %w", err) + } + changeID = strings.TrimSpace(changeID) + + // Check if task is a parent of @ + parents, err := client.GetParents("@") + if err != nil { + return false, fmt.Errorf("getting @ parents: %w", err) + } + + isParent := false + var otherParents []string + for _, p := range parents { + if p == changeID { + isParent = true + } else { + otherParents = append(otherParents, p) + } + } + + // Warn about empty task or uncommitted work before marking done + checkEmptyTask(cmd, changeID) + checkWorkingCopyDiff(cmd, changeID, "done") + + // Mark as done + if err := setTaskFlag(rev, "done"); err != nil { + return false, fmt.Errorf("setting flag: %w", err) + } + + // If task is a merge parent, linearize: rebase other parents onto done task + if isParent && len(otherParents) > 0 { + if err := linearizeDoneTask(changeID, otherParents); err != nil { + return false, fmt.Errorf("linearizing: %w", err) + } + } + + // Check if task ended up in @'s ancestry + inAncestry, err := client.IsAncestorOf(changeID, "@") + if err != nil { + return false, nil // Ignore error, just skip orphan check + } + + return !inAncestry, nil +} + +// linearizeDoneTask rebases other merge parents onto the done task, +// then rebases @ onto the top of the new linear chain +func linearizeDoneTask(doneTask string, otherParents []string) error { + // Rebase each other parent (with descendants) onto the done task + base := doneTask + for _, parent := range otherParents { + if err := client.Run("rebase", "-s", parent, "-o", base); err != nil { + return fmt.Errorf("rebasing %s onto %s: %w", parent, base, err) + } + base = parent + } + + // Rebase @ onto the last parent (now linear on top of done task) + if err := client.Run("rebase", "-s", "@", "-o", base); err != nil { + return fmt.Errorf("rebasing @ onto %s: %w", base, err) + } + + return nil +} + +func printOrphanWarning(orphans []string) { + revList := strings.Join(orphans, " ") + revUnion := strings.Join(orphans, " | ") + + _, _ = fmt.Fprintf(os.Stderr, "\nWarning: %s marked done but not in @'s ancestry (orphan tasks)\n", revList) + _, _ = fmt.Fprintln(os.Stderr, "These tasks were never 'wip' - their specs won't be in linear history.") + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintln(os.Stderr, "Options:") + _, _ = fmt.Fprintln(os.Stderr, " 1. Consolidate specs into @ description, then abandon tasks") + _, _ = fmt.Fprintln(os.Stderr, " 2. Linearize into ancestry (may conflict)") + _, _ = fmt.Fprintln(os.Stderr, " 3. Leave as-is (manual cleanup later)") + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintf(os.Stderr, "View specs: jj log -r '%s' --no-graph -T description\n", revUnion) + _, _ = fmt.Fprintln(os.Stderr) +} + +func init() { + rootCmd.AddCommand(doneCmd) +} diff --git a/cmd/jjtask/cmd/drop.go b/cmd/jjtask/cmd/drop.go new file mode 100644 index 0000000..4ce376b --- /dev/null +++ b/cmd/jjtask/cmd/drop.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var dropAbandon bool + +var dropCmd = &cobra.Command{ + Use: "drop <tasks...>", + Short: "Remove tasks from @ merge", + Long: `Remove tasks from the @ merge without marking them done. + +By default, marks tasks as 'standby' so they can be re-added later. +Use --abandon to permanently remove the tasks. + +Examples: + jjtask drop xyz # Mark as standby, remove from @ + jjtask drop a b c # Drop multiple tasks + jjtask drop --abandon xyz # Abandon task entirely`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + for _, rev := range args { + if err := dropTask(rev); err != nil { + return fmt.Errorf("failed to drop %s: %w", rev, err) + } + } + return nil + }, +} + +func dropTask(rev string) error { + // Get change ID + changeID, err := client.Query("log", "-r", rev, "--no-graph", "-T", "change_id.shortest()") + if err != nil { + return fmt.Errorf("getting change ID: %w", err) + } + changeID = strings.TrimSpace(changeID) + + if dropAbandon { + if err := client.Run("abandon", rev); err != nil { + return fmt.Errorf("abandoning: %w", err) + } + fmt.Printf("Abandoned %s\n", rev) + } else { + if err := setTaskFlag(rev, "standby"); err != nil { + return fmt.Errorf("setting flag: %w", err) + } + fmt.Printf("Marked %s as standby\n", rev) + } + + // Remove from @ merge (preserves @ content) + if err := client.RemoveFromMerge(changeID); err != nil { + return fmt.Errorf("removing from merge: %w", err) + } + + return nil +} + +func init() { + rootCmd.AddCommand(dropCmd) + dropCmd.Flags().BoolVar(&dropAbandon, "abandon", false, "Abandon the tasks entirely") +} diff --git a/cmd/jjtask/cmd/finalize.go b/cmd/jjtask/cmd/finalize.go deleted file mode 100644 index c8b4c52..0000000 --- a/cmd/jjtask/cmd/finalize.go +++ /dev/null @@ -1,49 +0,0 @@ -package cmd - -import ( - "fmt" - "regexp" - - "github.com/spf13/cobra" -) - -var finalizeCmd = &cobra.Command{ - Use: "finalize [rev]", - Short: "Strip task prefix for final commit", - Long: `Remove the [task:*] prefix from a revision description. - -This is typically used after a task is marked done and ready -to be treated as a regular commit. - -Examples: - jjtask finalize @ - jjtask finalize mxyz`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - rev := "@" - if len(args) == 1 { - rev = args[0] - } - - desc, err := client.GetDescription(rev) - if err != nil { - return fmt.Errorf("failed to get description: %w", err) - } - - // Remove [task:*] prefix - pattern := regexp.MustCompile(`^\[task:\w+\]\s*`) - newDesc := pattern.ReplaceAllString(desc, "") - - if newDesc == desc { - fmt.Println("No [task:*] prefix found") - return nil - } - - return client.SetDescription(rev, newDesc) - }, -} - -func init() { - rootCmd.AddCommand(finalizeCmd) - finalizeCmd.ValidArgsFunction = completeTaskRevision -} diff --git a/cmd/jjtask/cmd/find.go b/cmd/jjtask/cmd/find.go index e832f75..8c9b629 100644 --- a/cmd/jjtask/cmd/find.go +++ b/cmd/jjtask/cmd/find.go @@ -13,7 +13,10 @@ import ( "jjtask/internal/workspace" ) -var findFormat string +var ( + findFormat string + findStatus string +) type TaskItem struct { ChangeID string `json:"change_id"` @@ -32,24 +35,23 @@ type FindOutput struct { var findRevset string var findCmd = &cobra.Command{ - Use: "find [flag]", + Use: "find [--status STATUS] [--revisions REVSET]", Short: "List tasks", - Long: `List tasks matching a flag filter or custom revset. + Long: `List tasks matching a status filter or custom revset. -Without arguments, shows pending tasks. With a flag argument, shows tasks -matching that flag. Use -r for custom revsets. +Without arguments, shows pending tasks. With --status, shows tasks +matching that status. Use -r for custom revsets. -Flag shortcuts: pending, todo, wip, done, blocked, standby, untested, draft, review, all +Status options: pending, todo, wip, done, blocked, standby, untested, draft, review, all Examples: - jjtask find # pending tasks (default) - jjtask find todo # todo tasks only - jjtask find wip # work in progress - jjtask find done # completed tasks - jjtask find all # all tasks including done - jjtask find -r 'tasks() & mine()' # custom revset - jjtask find -r 'ancestors(tasks_pending(), 3)' # tasks with context`, - Args: cobra.MaximumNArgs(1), + jjtask find # pending tasks (default) + jjtask find --status todo # todo tasks only + jjtask find -s wip # work in progress + jjtask find -s done # completed tasks + jjtask find -s all # all tasks including done + jjtask find -r 'tasks() & mine()'`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { var revset string customRevset := findRevset != "" @@ -59,9 +61,9 @@ Examples: revset = fmt.Sprintf("(%s) & tasks()", findRevset) } else { taskRevset := "tasks_pending()" - if len(args) == 1 { - flag := args[0] - switch flag { + status := findStatus + if status != "" { + switch status { case "pending": taskRevset = "tasks_pending()" case "todo": @@ -83,11 +85,11 @@ Examples: case "all": taskRevset = "tasks()" default: - return fmt.Errorf("unknown flag %q", flag) + return fmt.Errorf("unknown status %q", status) } } // Show connected DAG for active tasks, plain list for done/all - if len(args) == 1 && (args[0] == "done" || args[0] == "all") { + if status == "done" || status == "all" { revset = taskRevset } else { revset = fmt.Sprintf("%s | fork_point(%s | @)::@", taskRevset, taskRevset) @@ -162,10 +164,11 @@ Examples: } func init() { + findCmd.Flags().StringVarP(&findStatus, "status", "s", "", "Filter by status (pending, todo, wip, done, blocked, standby, untested, draft, review, all)") findCmd.Flags().StringVarP(&findRevset, "revisions", "r", "", "Custom revset to filter tasks") findCmd.Flags().StringVar(&findFormat, "format", "text", "Output format: text or json") rootCmd.AddCommand(findCmd) - findCmd.ValidArgsFunction = completeFindFlag + _ = findCmd.RegisterFlagCompletionFunc("status", completeFindFlag) } func findJSON(repos []workspace.Repo, workspaceRoot, revset string, isMulti bool) error { diff --git a/cmd/jjtask/cmd/flag.go b/cmd/jjtask/cmd/flag.go index 7044230..d8bc224 100644 --- a/cmd/jjtask/cmd/flag.go +++ b/cmd/jjtask/cmd/flag.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "regexp" "slices" "strings" @@ -11,25 +12,43 @@ import ( var validFlags = []string{"draft", "todo", "wip", "untested", "standby", "review", "blocked", "done"} +var ( + flagRev string +) + var flagCmd = &cobra.Command{ - Use: "flag <rev> <flag>", - Short: "Update task status flag", + Use: "flag <status> [--rev REV]", + Short: "Update task status flag", + ValidArgs: validFlags, Long: `Update the [task:*] flag in a revision description. Valid flags: draft, todo, wip, untested, standby, review, blocked, done Examples: - jjtask flag @ wip - jjtask flag mxyz done`, - Args: cobra.ExactArgs(2), + jjtask flag wip + jjtask flag done --rev mxyz`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - rev := args[0] - toFlag := args[1] + toFlag := args[0] + rev := flagRev if !slices.Contains(validFlags, toFlag) { return fmt.Errorf("invalid flag %q, must be one of: %s", toFlag, strings.Join(validFlags, ", ")) } + // Check for pending children and empty task when marking done + if toFlag == "done" { + checkPendingChildren(cmd, rev) + checkEmptyTask(cmd, rev) + } + + // Check for blocked ancestors, done ancestors, and existing WIP when marking wip + if toFlag == "wip" { + checkBlockedAncestors(cmd, rev) + checkDoneAncestors(cmd, rev) + checkExistingWip(cmd, rev) + } + desc, err := client.GetDescription(rev) if err != nil { return fmt.Errorf("failed to get description: %w", err) @@ -52,29 +71,228 @@ Examples: return fmt.Errorf("failed to set description: %w", err) } - // After marking done, check for stale tasks - if toFlag == "done" { - stale, err := client.Query("log", "-r", "tasks_stale()", "--no-graph", "-T", "change_id.shortest() ++ \" \"") - if err == nil && strings.TrimSpace(stale) != "" { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Stale tasks: %s- consider: jjtask hoist or jj rebase -s TASK -d @\n", stale) - } - } + // Check if @ has uncommitted work and is not the task being marked + checkWorkingCopyDiff(cmd, rev, toFlag) + + fmt.Fprintln(os.Stderr, "Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow") return nil }, } +// checkExistingWip warns when marking a new task as WIP while another WIP exists +// Returns error if the new WIP task is not in the same chain as existing WIP +func checkExistingWip(cmd *cobra.Command, newWipRev string) { + // Find existing WIP tasks (excluding the one we're about to mark) + wipOutput, err := client.Query("log", "-r", fmt.Sprintf("tasks_wip() ~ %s", newWipRev), "--no-graph", "-T", `change_id.shortest() ++ " " ++ description.first_line() ++ "\n"`) + if err != nil || strings.TrimSpace(wipOutput) == "" { + return // No other WIP task + } + + wipLine := strings.TrimSpace(strings.Split(wipOutput, "\n")[0]) + wipParts := strings.SplitN(wipLine, " ", 2) + if len(wipParts) == 0 { + return + } + wipID := wipParts[0] + wipTitle := "" + if len(wipParts) > 1 { + wipTitle = wipParts[1] + } + + // Check if new WIP is ancestor or descendant of existing WIP (same chain) + checkRevset := fmt.Sprintf("(%s & (ancestors(%s) | descendants(%s)))", newWipRev, wipID, wipID) + result, err := client.Query("log", "-r", checkRevset, "--no-graph", "-T", "change_id.shortest()", "--limit", "1") + if err == nil && strings.TrimSpace(result) != "" { + return // In same chain, OK + } + + // Not in same chain - warn + stderr := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(stderr) + _, _ = fmt.Fprintf(stderr, "⚠️ Another WIP task exists: %s %s\n", wipID, wipTitle) + _, _ = fmt.Fprintln(stderr, "Multiple WIP tasks in different branches can be confusing.") + _, _ = fmt.Fprintln(stderr, "Options:") + _, _ = fmt.Fprintf(stderr, " • Pause existing: jjtask flag blocked -r %s\n", wipID) + _, _ = fmt.Fprintf(stderr, " • Switch to existing: jj edit %s\n", wipID) + _, _ = fmt.Fprintf(stderr, " • Rebase to chain: jj rebase -s %s -d %s\n", newWipRev, wipID) + _, _ = fmt.Fprintln(stderr) +} + +// checkDoneAncestors warns if any ancestor task is done +func checkDoneAncestors(cmd *cobra.Command, taskRev string) { + // Get done ancestors (tasks with [task:done] prefix) + doneRevset := fmt.Sprintf("ancestors(%s) & tasks_done()", taskRev) + output, err := client.Query("log", "-r", doneRevset, "--no-graph", "-T", `change_id.shortest() ++ " " ++ description.first_line() ++ "\n"`, "--limit", "3") + if err != nil { + return + } + output = strings.TrimSpace(output) + if output == "" { + return + } + + lines := strings.Split(output, "\n") + if len(lines) == 0 { + return + } + + stderr := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(stderr) + _, _ = fmt.Fprintln(stderr, "⚠️ Ancestor task is already done:") + for _, line := range lines { + _, _ = fmt.Fprintf(stderr, " • %s\n", line) + } + _, _ = fmt.Fprintln(stderr, "Starting work below done tasks is unusual. Consider:") + _, _ = fmt.Fprintln(stderr, " • Rebase as sibling: jj rebase -s", taskRev, "-d <done-task>~") + _, _ = fmt.Fprintln(stderr, " • Or squash done tasks: jjtask squash -r <done-task>") + _, _ = fmt.Fprintln(stderr) +} + +// checkBlockedAncestors warns if any ancestor task is blocked +func checkBlockedAncestors(cmd *cobra.Command, taskRev string) { + // Get blocked ancestors + blockedRevset := fmt.Sprintf("ancestors(%s) & tasks_blocked()", taskRev) + output, err := client.Query("log", "-r", blockedRevset, "--no-graph", "-T", `change_id.shortest() ++ " " ++ description.first_line() ++ "\n"`) + if err != nil { + return + } + output = strings.TrimSpace(output) + if output == "" { + return + } + + lines := strings.Split(output, "\n") + if len(lines) == 0 { + return + } + + stderr := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(stderr) + _, _ = fmt.Fprintln(stderr, "⚠️ Ancestor task is blocked:") + for _, line := range lines { + _, _ = fmt.Fprintf(stderr, " • %s\n", line) + } + _, _ = fmt.Fprintln(stderr, "Consider unblocking the ancestor first.") + _, _ = fmt.Fprintln(stderr) +} + +// checkPendingChildren warns if task has pending child tasks +func checkPendingChildren(cmd *cobra.Command, taskRev string) { + // Get pending children (tasks that are not done) + pendingRevset := fmt.Sprintf("children(%s) & tasks_pending()", taskRev) + output, err := client.Query("log", "-r", pendingRevset, "--no-graph", "-T", `change_id.shortest() ++ " " ++ description.first_line() ++ "\n"`) + if err != nil { + return + } + output = strings.TrimSpace(output) + if output == "" { + return + } + + lines := strings.Split(output, "\n") + if len(lines) == 0 { + return + } + + stderr := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(stderr) + _, _ = fmt.Fprintf(stderr, "⚠️ Task has %d pending children:\n", len(lines)) + for _, line := range lines { + _, _ = fmt.Fprintf(stderr, " • %s\n", line) + } + _, _ = fmt.Fprintln(stderr, "Consider marking children done first, or they may be orphaned.") + _, _ = fmt.Fprintln(stderr) +} + +// checkEmptyTask warns if marking an empty revision as done +func checkEmptyTask(cmd *cobra.Command, taskRev string) { + diff, err := client.Query("diff", "-r", taskRev, "--stat") + if err != nil { + return + } + diff = strings.TrimSpace(diff) + if diff != "" && !strings.HasPrefix(diff, "0 files changed") { + return // has content + } + + stderr := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(stderr) + _, _ = fmt.Fprintln(stderr, "⚠️ Task is empty - no changes to mark done") + _, _ = fmt.Fprintln(stderr, "If this is a planning-only task, this warning can be ignored.") + _, _ = fmt.Fprintln(stderr) +} + +// checkWorkingCopyDiff warns if @ has changes that might belong to the task +func checkWorkingCopyDiff(cmd *cobra.Command, taskRev, _flag string) { + // Get @ change id + atID, err := client.Query("log", "-r", "@", "--no-graph", "-T", "change_id.shortest()") + if err != nil { + return + } + atID = strings.TrimSpace(atID) + + // Get task change id + taskID, err := client.Query("log", "-r", taskRev, "--no-graph", "-T", "change_id.shortest()") + if err != nil { + return + } + taskID = strings.TrimSpace(taskID) + + // If @ is the task, no warning needed + if atID == taskID { + return + } + + // Check if @ has a diff (actual file changes, not just the summary line) + diff, err := client.Query("diff", "-r", "@", "--stat") + if err != nil { + return + } + diff = strings.TrimSpace(diff) + // Empty diff or only summary line with "0 files changed" means no real changes + if diff == "" || strings.HasPrefix(diff, "0 files changed") { + return + } + + // @ has changes and is not the task - warn + stderr := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(stderr) + _, _ = fmt.Fprintln(stderr, "⚠️ Working copy (@) has uncommitted changes:") + _, _ = fmt.Fprintln(stderr, diff) + _, _ = fmt.Fprintln(stderr, "Were any of these changes part of this task?") + _, _ = fmt.Fprintln(stderr) +} + +// setTaskFlag is a helper to update a task's flag in its description +func setTaskFlag(rev, flag string) error { + desc, err := client.GetDescription(rev) + if err != nil { + return err + } + + taskPattern := regexp.MustCompile(`^\[task:(\w+)\]`) + var newDesc string + if taskPattern.MatchString(desc) { + newDesc = taskPattern.ReplaceAllString(desc, fmt.Sprintf("[task:%s]", flag)) + } else { + newDesc = fmt.Sprintf("[task:%s] %s", flag, desc) + } + return client.SetDescription(rev, newDesc) +} + func init() { rootCmd.AddCommand(flagCmd) - // arg 0: revision, arg 1: flag + flagCmd.Flags().StringVarP(&flagRev, "rev", "r", "@", "revision to update") + flagCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completeTaskRevision(cmd, args, toComplete) - } - if len(args) == 1 { return completeTaskFlag(cmd, args, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp } + + // Complete --rev flag with task revisions + _ = flagCmd.RegisterFlagCompletionFunc("rev", completeTaskRevision) } diff --git a/cmd/jjtask/cmd/hoist.go b/cmd/jjtask/cmd/hoist.go deleted file mode 100644 index 1de1f6b..0000000 --- a/cmd/jjtask/cmd/hoist.go +++ /dev/null @@ -1,80 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" -) - -var hoistCmd = &cobra.Command{ - Use: "hoist", - Short: "Rebase pending tasks to children of @", - Long: `Rebase stale pending tasks so they become children of the current -working copy (@). - -This is useful after doing work when tasks have become "stale" -(not in the ancestry of @). After hoisting, tasks are children of @ -so you can jj edit them to start work. - -Examples: - jjtask hoist`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - // Find task roots that need hoisting (stale tasks that are roots of pending subtrees) - out, err := client.Query("log", "-r", "roots(tasks_stale())", "--no-graph", "-T", "change_id ++ \"\\n\"") - if err != nil { - return fmt.Errorf("failed to find stale tasks: %w", err) - } - - roots := strings.Split(strings.TrimSpace(out), "\n") - if len(roots) == 0 || (len(roots) == 1 && roots[0] == "") { - fmt.Println("No stale tasks to hoist") - return nil - } - - // Filter empty strings - var validRoots []string - for _, r := range roots { - r = strings.TrimSpace(r) - if r != "" { - validRoots = append(validRoots, r) - } - } - - if len(validRoots) == 0 { - fmt.Println("No stale tasks to hoist") - return nil - } - - // Get short IDs for display - shortIDs := make([]string, 0, len(validRoots)) - for _, r := range validRoots { - short, err := client.Query("log", "-r", r, "--no-graph", "-T", "change_id.shortest()") - if err == nil { - shortIDs = append(shortIDs, strings.TrimSpace(short)) - } - } - - fmt.Printf("Found %d task root(s) to hoist: %s\n", len(validRoots), strings.Join(shortIDs, " ")) - - // Build single rebase command with multiple -s flags - rebaseArgs := make([]string, 0, 2+len(validRoots)*2) - for _, root := range validRoots { - rebaseArgs = append(rebaseArgs, "-s", root) - } - rebaseArgs = append(rebaseArgs, "-d", "@") - - fullArgs := append([]string{"rebase"}, rebaseArgs...) - if err := client.Run(fullArgs...); err != nil { - return fmt.Errorf("failed to rebase tasks: %w", err) - } - - fmt.Println("Done") - return nil - }, -} - -func init() { - rootCmd.AddCommand(hoistCmd) -} diff --git a/cmd/jjtask/cmd/megamerge_test.go b/cmd/jjtask/cmd/megamerge_test.go new file mode 100644 index 0000000..0b4231f --- /dev/null +++ b/cmd/jjtask/cmd/megamerge_test.go @@ -0,0 +1,612 @@ +package cmd_test + +import ( + "strings" + "testing" +) + +func TestWipSingleTask(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Single task") + taskID := repo.GetTaskID("todo") + + repo.Run("jjtask", "wip", taskID) + + // Task should have wip flag + desc := repo.runSilent("jj", "log", "-r", taskID, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:wip]") { + t.Error("task should have wip flag") + } + + // Task should be a parent of @ + parents := repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + if !strings.Contains(parents, strings.TrimSpace(repo.runSilent("jj", "log", "-r", taskID, "--no-graph", "-T", "change_id.shortest()"))) { + t.Error("task should be parent of @") + } + + output := repo.Run("jjtask", "find") + if !strings.Contains(output, "[task:wip]") { + t.Error("task should appear in find with wip flag") + } +} + +func TestWipMultipleTasks(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task A") + taskA := repo.GetTaskID("todo") + + // Create second task as sibling (not child) + repo.Run("jjtask", "create", "--parent", "root()", "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + + // Mark first as wip + repo.Run("jjtask", "wip", taskA) + + // Mark second as wip - should create merge + repo.Run("jjtask", "wip", taskB) + + // @ should have multiple parents (merge commit) + parentOut := repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + parentCount := len(strings.Split(strings.TrimSpace(parentOut), "\n")) + + if parentCount < 2 { + t.Errorf("expected 2+ parents (merge), got %d", parentCount) + } +} + +func TestDoneWithContent(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task with content") + taskID := repo.GetTaskID("todo") + + // Edit into task and add content + repo.Run("jj", "edit", taskID) + repo.WriteFile("workfile.txt", "actual work") + repo.Run("jj", "status") // Trigger snapshot + + // Mark as wip first (to make it active) + repo.Run("jjtask", "wip") + + // Now mark done + repo.Run("jjtask", "done") + + // Task should be marked done + desc := repo.runSilent("jj", "log", "-r", taskID, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:done]") { + t.Errorf("expected [task:done], got: %s", desc) + } +} + +func TestDoneEmptyTask(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Empty planning task") + taskID := repo.GetTaskID("todo") + + // Mark wip without adding content + repo.Run("jjtask", "wip", taskID) + + // Mark done (empty) - need to specify the task since @ is child of task + repo.Run("jjtask", "done", taskID) + + // Task should be marked done + desc := repo.runSilent("jj", "log", "-r", taskID, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:done]") { + t.Errorf("expected [task:done], got: %s", desc) + } +} + +func TestDropMarksStandby(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task to drop") + taskID := repo.GetTaskID("todo") + + // Mark wip first + repo.Run("jjtask", "wip", taskID) + + // Drop it + output := repo.Run("jjtask", "drop", taskID) + + if !strings.Contains(output, "standby") { + t.Errorf("expected standby message, got: %s", output) + } + + // Verify it's marked standby + desc := repo.runSilent("jj", "log", "-r", taskID, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:standby]") { + t.Error("task should be marked standby") + } +} + +func TestDropAbandon(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task to abandon") + taskID := repo.GetTaskID("todo") + + output := repo.Run("jjtask", "drop", "--abandon", taskID) + + if !strings.Contains(output, "Abandoned") { + t.Errorf("expected Abandoned message, got: %s", output) + } + + // Task should no longer exist + check := repo.RunExpectFail("jj", "log", "-r", taskID, "--no-graph") + if !strings.Contains(check, "doesn't exist") && !strings.Contains(check, "No matching revisions") { + t.Error("task should not exist after abandon") + } +} + +func TestDoneLinearizesFromMerge(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + // Create base commit (not on root to avoid git limitations) + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base commit") + + // Create Task A as child of base + repo.Run("jjtask", "create", "Task A") + taskA := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task A")`, + "--no-graph", "-T", "change_id.shortest()")) + + // Create Task B as sibling of Task A (also child of base) + baseID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `description(substring:"Base commit")`, + "--no-graph", "-T", "change_id.shortest()")) + repo.Run("jjtask", "create", "--parent", baseID, "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + + // Mark both as WIP - creates merge + repo.Run("jjtask", "wip", taskA) + repo.Run("jjtask", "wip", taskB) + + // Verify merge (2+ parents) + parentOut := repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + parentCount := len(strings.Split(strings.TrimSpace(parentOut), "\n")) + if parentCount < 2 { + t.Fatalf("expected merge with 2+ parents, got %d", parentCount) + } + + // Mark task A as done - should linearize + repo.Run("jjtask", "done", taskA) + + // Task A should be ancestor of Task B now + isAncestor := repo.runSilent("jj", "log", "-r", taskA+"::"+taskB, "--no-graph", "-T", "change_id.shortest()") + if isAncestor == "" || !strings.Contains(isAncestor, taskA) { + t.Errorf("done task A should be ancestor of task B after linearization, got: %s", isAncestor) + } + + // @ should have single parent now (linear chain) + parentOut = repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + lines := strings.Split(strings.TrimSpace(parentOut), "\n") + var nonEmpty []string + for _, line := range lines { + if strings.TrimSpace(line) != "" { + nonEmpty = append(nonEmpty, line) + } + } + if len(nonEmpty) != 1 { + t.Errorf("expected single parent after linearization, got %d", len(nonEmpty)) + } +} + +func TestSquashFlatten(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + // Create a common base (not root) to avoid Git merge-with-root issue + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base commit") + + repo.Run("jjtask", "create", "Task A") + taskA := repo.GetTaskID("todo") + + // Add content to task A + repo.Run("jj", "edit", taskA) + repo.WriteFile("file_a.txt", "content A") + repo.Run("jj", "status") + + // Create Task B as sibling (same parent as Task A) + baseID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `description(substring:"Base commit")`, + "--no-graph", "-T", "change_id.shortest()")) + repo.Run("jjtask", "create", "--parent", baseID, "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + + // Add content to task B + repo.Run("jj", "edit", taskB) + repo.WriteFile("file_b.txt", "content B") + repo.Run("jj", "status") + + // Mark both as wip to create merge + repo.Run("jjtask", "wip", taskA) + repo.Run("jjtask", "wip", taskB) + + // Verify we have a merge + parentOut := repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + parentCount := len(strings.Split(strings.TrimSpace(parentOut), "\n")) + if parentCount < 2 { + t.Fatalf("expected merge with 2+ parents before squash, got %d", parentCount) + } + + // Squash + output := repo.Run("jjtask", "squash") + + if !strings.Contains(output, "Squashed") { + t.Errorf("expected squash message, got: %s", output) + } + + // @ should now have single parent (linear) + parentOut = repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + lines := strings.Split(strings.TrimSpace(parentOut), "\n") + // Filter empty lines + var nonEmpty []string + for _, line := range lines { + if strings.TrimSpace(line) != "" { + nonEmpty = append(nonEmpty, line) + } + } + if len(nonEmpty) > 1 { + t.Errorf("expected single parent after squash, got %d", len(nonEmpty)) + } +} + +func TestSquashSingleParent(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Single task") + taskID := repo.GetTaskID("todo") + + repo.Run("jjtask", "wip", taskID) + + output := repo.Run("jjtask", "squash") + + if !strings.Contains(output, "Only one parent") && !strings.Contains(output, "No parents") && !strings.Contains(output, "single parent") { + t.Errorf("expected single parent message, got: %s", output) + } +} + +// === Edge case tests discovered during development === + +func TestWipWhenAtIsTask(t *testing.T) { + // When @ IS the task itself, wip should just mark it, not try to add as parent + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task") + taskID := repo.GetTaskID("todo") + + // Edit into the task so @ = task + repo.Run("jj", "edit", taskID) + + // Mark as wip - should not error "cannot rebase onto itself" + repo.Run("jjtask", "wip") + + desc := repo.runSilent("jj", "log", "-r", "@", "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:wip]") { + t.Errorf("expected [task:wip], got: %s", desc) + } +} + +func TestDoneWhenAtIsTask(t *testing.T) { + // When @ IS the task, done should just mark it + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task") + taskID := repo.GetTaskID("todo") + + repo.Run("jj", "edit", taskID) + repo.WriteFile("work.txt", "content") + repo.Run("jjtask", "wip") + repo.Run("jjtask", "done") + + desc := repo.runSilent("jj", "log", "-r", "@", "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:done]") { + t.Errorf("expected [task:done], got: %s", desc) + } +} + +func TestWipMultipleRevsAtOnce(t *testing.T) { + // wip a b c should mark all as wip and add all to merge + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + baseID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", "@", "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task A") + taskA := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task A")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task C") + taskC := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task C")`, + "--no-graph", "-T", "change_id.shortest()")) + + // Mark all three at once + repo.Run("jjtask", "wip", taskA, taskB, taskC) + + // All should be wip + for _, task := range []string{taskA, taskB, taskC} { + desc := repo.runSilent("jj", "log", "-r", task, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:wip]") { + t.Errorf("task %s should be wip, got: %s", task, desc) + } + } + + // @ should have 3+ parents + parentOut := repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + lines := strings.Split(strings.TrimSpace(parentOut), "\n") + if len(lines) < 3 { + t.Errorf("expected 3+ parents, got %d", len(lines)) + } +} + +func TestDoneMultipleRevsAtOnce(t *testing.T) { + // done a b should mark both done + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + + repo.Run("jjtask", "create", "Task A") + taskA := repo.GetTaskID("todo") + repo.Run("jj", "edit", taskA) + repo.WriteFile("a.txt", "a") + + repo.Run("jjtask", "create", "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + repo.Run("jj", "edit", taskB) + repo.WriteFile("b.txt", "b") + + repo.Run("jjtask", "wip", taskA) + repo.Run("jjtask", "wip", taskB) + + // Mark both done at once + repo.Run("jjtask", "done", taskA, taskB) + + // Both should be done + for _, task := range []string{taskA, taskB} { + desc := repo.runSilent("jj", "log", "-r", task, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:done]") { + t.Errorf("task %s should be done, got: %s", task, desc) + } + } +} + +func TestDropMultipleRevsAtOnce(t *testing.T) { + // drop a b should remove both from merge + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + baseID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", "@", "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task A") + taskA := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task A")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "wip", taskA) + repo.Run("jjtask", "wip", taskB) + + // Drop both at once + repo.Run("jjtask", "drop", taskA, taskB) + + // Both should be standby + for _, task := range []string{taskA, taskB} { + desc := repo.runSilent("jj", "log", "-r", task, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:standby]") { + t.Errorf("task %s should be standby, got: %s", task, desc) + } + } +} + +func TestDoneThreeWayMergeLinearizes(t *testing.T) { + // With 3 WIP tasks in merge, done on first should linearize all + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + baseID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", "@", "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task A") + taskA := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task A")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task C") + taskC := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task C")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "wip", taskA) + repo.Run("jjtask", "wip", taskB) + repo.Run("jjtask", "wip", taskC) + + // Verify 3-way merge + parentOut := repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + if len(strings.Split(strings.TrimSpace(parentOut), "\n")) < 3 { + t.Fatal("expected 3-way merge") + } + + // Mark A as done - should linearize + repo.Run("jjtask", "done", taskA) + + // @ should now have single parent (linear) + parentOut = repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + lines := strings.Split(strings.TrimSpace(parentOut), "\n") + var nonEmpty []string + for _, l := range lines { + if strings.TrimSpace(l) != "" { + nonEmpty = append(nonEmpty, l) + } + } + if len(nonEmpty) != 1 { + t.Errorf("expected single parent after linearization, got %d: %v", len(nonEmpty), nonEmpty) + } + + // A should be ancestor of B and C + ancestorCheck := repo.runSilent("jj", "log", "-r", taskA+"::("+taskB+"|"+taskC+")", "--no-graph", "-T", "change_id.shortest()") + if !strings.Contains(ancestorCheck, taskA) { + t.Error("task A should be ancestor of B and C") + } +} + +func TestWipPreservesAtContent(t *testing.T) { + // When @ has content and we add a wip task, content should be preserved + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + + // Create @ with content + repo.Run("jj", "new", "-m", "Working commit") + repo.WriteFile("work.txt", "my work") + repo.Run("jj", "status") // snapshot + + // Create a task as sibling + baseID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `description(substring:"Base")`, + "--no-graph", "-T", "change_id.shortest()")) + repo.Run("jjtask", "create", "--parent", baseID, "Task") + taskID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks()`, + "--no-graph", "-T", "change_id.shortest()")) + + // Mark task as wip - @ should still have work.txt + repo.Run("jjtask", "wip", taskID) + + // Check @ still has the file + diff := repo.runSilent("jj", "diff", "-r", "@", "--stat") + if !strings.Contains(diff, "work.txt") { + t.Errorf("@ should still have work.txt after wip, diff: %s", diff) + } +} + +func TestDropPreservesAtContent(t *testing.T) { + // When @ has content and we drop a task, content should be preserved + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + baseID := strings.TrimSpace(repo.runSilent("jj", "log", "-r", "@", "--no-graph", "-T", "change_id.shortest()")) + + // Create two tasks + repo.Run("jjtask", "create", "--parent", baseID, "Task A") + taskA := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task A")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "create", "--parent", baseID, "Task B") + taskB := strings.TrimSpace(repo.runSilent("jj", "log", "-r", `tasks() & description(substring:"Task B")`, + "--no-graph", "-T", "change_id.shortest()")) + + repo.Run("jjtask", "wip", taskA) + repo.Run("jjtask", "wip", taskB) + + // Add content to @ (merge) + repo.WriteFile("merge_work.txt", "merge content") + repo.Run("jj", "status") + + // Drop task A + repo.Run("jjtask", "drop", taskA) + + // @ should still have merge_work.txt + diff := repo.runSilent("jj", "diff", "-r", "@", "--stat") + if !strings.Contains(diff, "merge_work.txt") { + t.Errorf("@ should still have merge_work.txt after drop, diff: %s", diff) + } +} + +func TestDoneOrphanWarning(t *testing.T) { + // When marking done on task that was never wip (not in @'s ancestry), should warn + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + + // Create task but never mark as wip + repo.Run("jjtask", "create", "Orphan task") + taskID := repo.GetTaskID("todo") + + // Work happens in @ directly, not in task + repo.Run("jj", "new", "-m", "Working commit") + repo.WriteFile("work.txt", "actual work") + repo.Run("jj", "status") + + // Mark task done without ever doing wip - should warn about orphan + output := repo.Run("jjtask", "done", taskID) + + // Should have warning about orphan + if !strings.Contains(output, "not in @'s ancestry") && !strings.Contains(output, "orphan") { + t.Errorf("expected orphan warning, got: %s", output) + } + + // Task should still be marked done + desc := repo.runSilent("jj", "log", "-r", taskID, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:done]") { + t.Errorf("expected [task:done], got: %s", desc) + } +} + +func TestDoneSingleParentNoLinearization(t *testing.T) { + // When task is only parent (not a merge), done should just mark it + t.Parallel() + repo := SetupTestRepo(t) + + repo.WriteFile("base.txt", "base") + repo.Run("jj", "describe", "-m", "Base") + + repo.Run("jjtask", "create", "Task") + taskID := repo.GetTaskID("todo") + repo.Run("jj", "edit", taskID) + repo.WriteFile("task.txt", "content") + + repo.Run("jjtask", "wip") + + // @ is child of task (single parent) + repo.Run("jj", "new") + + // Mark task done + repo.Run("jjtask", "done", taskID) + + // Task should be done + desc := repo.runSilent("jj", "log", "-r", taskID, "--no-graph", "-T", "description") + if !strings.Contains(desc, "[task:done]") { + t.Errorf("expected [task:done], got: %s", desc) + } + + // @ parent should still be the task (no orphaning) + parentOut := repo.runSilent("jj", "log", "-r", "parents(@)", "--no-graph", "-T", "change_id.shortest()") + if !strings.Contains(parentOut, taskID) { + t.Errorf("@ parent should be task %s, got: %s", taskID, parentOut) + } +} diff --git a/cmd/jjtask/cmd/multirepo_test.go b/cmd/jjtask/cmd/multirepo_test.go new file mode 100644 index 0000000..d51b5d1 --- /dev/null +++ b/cmd/jjtask/cmd/multirepo_test.go @@ -0,0 +1,293 @@ +package cmd_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// MultiRepoTestEnv provides a multi-repo test environment +type MultiRepoTestEnv struct { + t *testing.T + rootDir string + repos map[string]*TestRepo +} + +// SetupMultiRepo creates a multi-repo test environment with .jj-workspaces.yaml +func SetupMultiRepo(t *testing.T) *MultiRepoTestEnv { + t.Helper() + + rootDir, err := os.MkdirTemp("", "jjtask-multi-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + env := &MultiRepoTestEnv{ + t: t, + rootDir: rootDir, + repos: make(map[string]*TestRepo), + } + + t.Cleanup(func() { + env.autoSnapshot(t) + _ = os.RemoveAll(rootDir) + }) + + // Create .jj-workspaces.yaml + workspacesYAML := `repos: + - path: frontend + name: frontend + - path: backend + name: backend + - path: . + name: root +` + if err := os.WriteFile(filepath.Join(rootDir, ".jj-workspaces.yaml"), []byte(workspacesYAML), 0o644); err != nil { + t.Fatalf("failed to write workspaces config: %v", err) + } + + // Create repos + for _, name := range []string{"frontend", "backend", "."} { + repoPath := filepath.Join(rootDir, name) + if name != "." { + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("failed to create repo dir: %v", err) + } + } + + repo := env.createRepoAt(repoPath) + if name == "." { + env.repos["root"] = repo + } else { + env.repos[name] = repo + } + } + + return env +} + +func (env *MultiRepoTestEnv) createRepoAt(dir string) *TestRepo { + env.t.Helper() + + repo := &TestRepo{ + t: env.t, + dir: dir, + log: &bytes.Buffer{}, + cmdCounter: 0, + } + + repo.baseEnv = makeBaseEnv(repo.findProjectRoot(), env.rootDir) + + repo.runSilent("jj", "git", "init", "--colocate") + return repo +} + +// RunInRoot runs a command from the root directory +func (env *MultiRepoTestEnv) RunInRoot(name string, args ...string) string { + env.t.Helper() + return env.repos["root"].Run(name, args...) +} + +// RunIn runs a command in a specific repo +func (env *MultiRepoTestEnv) RunIn(repoName, cmdName string, args ...string) string { + env.t.Helper() + repo, ok := env.repos[repoName] + if !ok { + env.t.Fatalf("unknown repo: %s", repoName) + } + return repo.Run(cmdName, args...) +} + +// autoSnapshot combines logs from all repos and saves snapshot +func (env *MultiRepoTestEnv) autoSnapshot(t *testing.T) { + var combined bytes.Buffer + for _, name := range []string{"root", "frontend", "backend"} { + repo := env.repos[name] + if repo != nil && repo.log.Len() > 0 { + combined.WriteString("=== " + name + " ===\n") + trace := repo.normalizeTrace(repo.log.String()) + // Also normalize the multi-repo root dir + trace = strings.ReplaceAll(trace, env.rootDir, "$ROOT") + combined.WriteString(trace) + combined.WriteString("\n") + } + } + + snapshotName := testNameToSnakeCase(t.Name()) + snapshotDir := filepath.Join(env.repos["root"].findProjectRoot(), "test", "snapshots_go") + snapshotFile := filepath.Join(snapshotDir, snapshotName+".txt") + trace := combined.String() + + if os.Getenv("SNAPSHOT_UPDATE") != "" { + _ = os.MkdirAll(snapshotDir, 0o755) + if err := os.WriteFile(snapshotFile, []byte(trace), 0o644); err != nil { + t.Fatalf("failed to write snapshot: %v", err) + } + return + } + + expected, err := os.ReadFile(snapshotFile) + if err != nil { + t.Fatalf("snapshot not found: %s\nRun with SNAPSHOT_UPDATE=1 to create\n\nActual output:\n%s", snapshotFile, trace) + } + + if string(expected) != trace { + t.Errorf("snapshot mismatch: %s\n\nExpected:\n%s\n\nActual:\n%s", snapshotName, expected, trace) + } +} + +func TestMultiRepoFind(t *testing.T) { + t.Parallel() + env := SetupMultiRepo(t) + + env.RunIn("frontend", "jjtask", "create", "Frontend task") + env.RunIn("backend", "jjtask", "create", "Backend task") + + output := env.RunInRoot("jjtask", "find") + + if !strings.Contains(output, "Frontend task") { + t.Error("frontend task not found in output") + } + if !strings.Contains(output, "Backend task") { + t.Error("backend task not found in output") + } + // Should show repo grouping + if !strings.Contains(output, "frontend") && !strings.Contains(output, "backend") { + t.Error("expected repo names in output") + } +} + +func TestMultiRepoAllLog(t *testing.T) { + t.Parallel() + env := SetupMultiRepo(t) + + env.repos["frontend"].WriteFile("file.txt", "test") + env.RunIn("frontend", "jj", "describe", "-m", "Frontend commit") + + env.repos["backend"].WriteFile("file.txt", "test") + env.RunIn("backend", "jj", "describe", "-m", "Backend commit") + + output := env.RunInRoot("jjtask", "all", "log", "-r", "@") + + if !strings.Contains(output, "Frontend commit") { + t.Error("frontend commit not found in all log") + } + if !strings.Contains(output, "Backend commit") { + t.Error("backend commit not found in all log") + } +} + +func TestMultiRepoWorkspaceHint(t *testing.T) { + t.Parallel() + env := SetupMultiRepo(t) + + env.RunIn("frontend", "jjtask", "create", "Frontend task") + + // Create subdirectory and run from there + subdir := filepath.Join(env.repos["frontend"].dir, "src") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("failed to create subdir: %v", err) + } + + // Run jjtask find from subdirectory + repo := env.repos["frontend"] + origDir := repo.dir + repo.dir = subdir + output := repo.Run("jjtask", "find") + repo.dir = origDir + + if !strings.Contains(output, "Frontend task") { + t.Error("task not found when running from subdirectory") + } +} + +func TestMultiRepoConfigMigration(t *testing.T) { + t.Parallel() + env := SetupMultiRepo(t) + + yamlPath := filepath.Join(env.rootDir, ".jj-workspaces.yaml") + tomlPath := filepath.Join(env.rootDir, ".jjtask.toml") + + // Verify YAML exists before migration + if _, err := os.Stat(yamlPath); err != nil { + t.Fatalf("expected .jj-workspaces.yaml to exist: %v", err) + } + + // Run any jjtask command to trigger migration + env.RunInRoot("jjtask", "find") + + // Verify TOML was created + if _, err := os.Stat(tomlPath); err != nil { + t.Fatalf("expected .jjtask.toml to be created: %v", err) + } + + // Verify YAML was removed + if _, err := os.Stat(yamlPath); err == nil { + t.Error("expected .jj-workspaces.yaml to be removed after migration") + } + + // Verify TOML content + data, err := os.ReadFile(tomlPath) + if err != nil { + t.Fatalf("failed to read migrated config: %v", err) + } + + content := string(data) + if !strings.Contains(content, "[workspaces]") { + t.Error("migrated config missing [workspaces] section") + } + if !strings.Contains(content, "frontend") || !strings.Contains(content, "backend") { + t.Error("migrated config missing repo entries") + } +} + +func TestMultiRepoComplex(t *testing.T) { + t.Parallel() + env := SetupMultiRepo(t) + + // Create tasks in root + env.RunIn("root", "jjtask", "create", "ROOT: CI/CD pipeline") + env.RunIn("root", "jjtask", "create", "--draft", "@", "ROOT: Terraform modules") + env.RunIn("root", "jjtask", "create", "ROOT: Integration tests") + + // Create tasks in frontend + env.RunIn("frontend", "jjtask", "create", "FE: Auth login page") + env.RunIn("frontend", "jjtask", "create", "--draft", "@", "FE: Dark mode toggle") + env.RunIn("frontend", "jjtask", "create", "FE: Error boundaries") + + // Create tasks in backend + env.RunIn("backend", "jjtask", "create", "BE: User API endpoints") + env.RunIn("backend", "jjtask", "create", "--draft", "@", "BE: GraphQL schema") + env.RunIn("backend", "jjtask", "create", "BE: Background jobs") + + output := env.RunInRoot("jjtask", "find", "--status", "all") + + // Verify all tasks exist + expected := []string{ + "ROOT: CI/CD pipeline", + "ROOT: Integration tests", + "FE: Auth login page", + "FE: Error boundaries", + "BE: User API endpoints", + "BE: Background jobs", + } + for _, task := range expected { + if !strings.Contains(output, task) { + t.Errorf("expected task %q not found in output", task) + } + } + + // Verify draft tasks exist when showing all + drafts := []string{ + "ROOT: Terraform modules", + "FE: Dark mode toggle", + "BE: GraphQL schema", + } + for _, task := range drafts { + if !strings.Contains(output, task) { + t.Errorf("expected draft task %q not found in output", task) + } + } +} diff --git a/cmd/jjtask/cmd/next.go b/cmd/jjtask/cmd/next.go deleted file mode 100644 index dfd89aa..0000000 --- a/cmd/jjtask/cmd/next.go +++ /dev/null @@ -1,199 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - "regexp" - "slices" - "strings" - - "github.com/spf13/cobra" -) - -var nextMarkAs string -var nextFormat string - -type NextTaskBrief struct { - ChangeID string `json:"change_id"` - Flag string `json:"flag"` - Title string `json:"title"` -} - -type NextOutput struct { - Revision string `json:"revision"` - ChangeID string `json:"change_id"` - Description string `json:"description"` - IsTask bool `json:"is_task"` - CurrentFlag string `json:"current_flag,omitempty"` - MarkedAs string `json:"marked_as,omitempty"` - NextTasks []NextTaskBrief `json:"next_tasks,omitempty"` - StaleTasks []string `json:"stale_tasks,omitempty"` - AvailableFlags []string `json:"available_flags,omitempty"` -} - -var nextCmd = &cobra.Command{ - Use: "next [rev]", - Short: "Review current task or transition to next", - Long: `Review the current task specification, optionally marking it -with a new status and transitioning to the next task. - -Without --mark-as, shows the current task's full description. -With --mark-as, updates the status and shows next task options. - -Examples: - jjtask next # Review current task - jjtask next --mark-as done # Mark current done, show next - jjtask next --mark-as wip xyz # Mark xyz as wip`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - rev := "@" - if len(args) == 1 { - rev = args[0] - } - - if nextFormat == "json" { - return nextJSON(cmd, rev) - } - - // Text output mode - if nextMarkAs != "" { - if !slices.Contains(validFlags, nextMarkAs) { - return fmt.Errorf("invalid flag %q, must be one of: %s", nextMarkAs, strings.Join(validFlags, ", ")) - } - - desc, err := client.GetDescription(rev) - if err != nil { - return fmt.Errorf("failed to get description: %w", err) - } - - if !strings.HasPrefix(desc, "[task:") { - return fmt.Errorf("revision %s is not a task", rev) - } - - _ = client.Run("--ignore-working-copy", "log", "-r", rev, "-n1", "--no-graph", "-T", "description") - - fmt.Printf("\nMarking %s as %s...\n", rev, nextMarkAs) - - flagArgs := []string{rev, nextMarkAs} - if err := flagCmd.RunE(cmd, flagArgs); err != nil { - return err - } - - fmt.Println("\nNext tasks:") - if err := client.Run("log", "-r", "tasks_next()", "-T", "task_log"); err != nil { - fmt.Println(" (no ready tasks)") - } - - stale, err := client.Query("log", "-r", "tasks_stale()", "--no-graph", "-T", "change_id.shortest() ++ \" \"") - if err == nil && strings.TrimSpace(stale) != "" { - fmt.Printf("\nStale tasks: %s- consider: jjtask hoist\n", stale) - } - - return nil - } - - desc, err := client.GetDescription(rev) - if err != nil { - return fmt.Errorf("failed to get description: %w", err) - } - - fmt.Println(desc) - - if strings.HasPrefix(desc, "[task:") { - fmt.Println("\n---") - fmt.Println("Transitions: jjtask next --mark-as <flag> [rev]") - fmt.Println("Flags: draft, todo, wip, untested, standby, review, blocked, done") - } - - return nil - }, -} - -func init() { - nextCmd.Flags().StringVar(&nextMarkAs, "mark-as", "", "Mark task with new status flag") - nextCmd.Flags().StringVar(&nextFormat, "format", "text", "Output format: text or json") - rootCmd.AddCommand(nextCmd) - - nextCmd.ValidArgsFunction = completeTaskRevision - _ = nextCmd.RegisterFlagCompletionFunc("mark-as", completeTaskFlag) -} - -func nextJSON(cmd *cobra.Command, rev string) error { - taskFlagRe := regexp.MustCompile(`\[task:(\w+)\]`) - - desc, err := client.GetDescription(rev) - if err != nil { - return fmt.Errorf("failed to get description: %w", err) - } - - changeID, err := client.Query("log", "-r", rev, "--no-graph", "-T", "change_id.shortest()") - if err != nil { - return fmt.Errorf("get change ID for %s: %w", rev, err) - } - changeID = strings.TrimSpace(changeID) - - output := NextOutput{ - Revision: rev, - ChangeID: changeID, - Description: desc, - IsTask: strings.HasPrefix(desc, "[task:"), - } - - if output.IsTask { - if match := taskFlagRe.FindStringSubmatch(desc); match != nil { - output.CurrentFlag = match[1] - } - output.AvailableFlags = validFlags - } - - // Handle --mark-as in JSON mode - if nextMarkAs != "" { - if !slices.Contains(validFlags, nextMarkAs) { - return fmt.Errorf("invalid flag %q, must be one of: %s", nextMarkAs, strings.Join(validFlags, ", ")) - } - if !output.IsTask { - return fmt.Errorf("revision %s is not a task", rev) - } - - flagArgs := []string{rev, nextMarkAs} - if err := flagCmd.RunE(cmd, flagArgs); err != nil { - return err - } - output.MarkedAs = nextMarkAs - } - - // Get next tasks - tmpl := `change_id.shortest() ++ "\t" ++ description.first_line() ++ "\n"` - nextOut, err := client.Query("log", "-r", "tasks_next()", "--no-graph", "-T", tmpl) - if err == nil && strings.TrimSpace(nextOut) != "" { - for _, line := range strings.Split(strings.TrimSpace(nextOut), "\n") { - parts := strings.SplitN(line, "\t", 2) - if len(parts) < 2 { - continue - } - brief := NextTaskBrief{ChangeID: parts[0]} - if match := taskFlagRe.FindStringSubmatch(parts[1]); match != nil { - brief.Flag = match[1] - brief.Title = strings.TrimSpace(taskFlagRe.ReplaceAllString(parts[1], "")) - } else { - brief.Title = parts[1] - } - output.NextTasks = append(output.NextTasks, brief) - } - } - - // Get stale tasks - staleOut, err := client.Query("log", "-r", "tasks_stale()", "--no-graph", "-T", "change_id.shortest() ++ \"\\n\"") - if err == nil && strings.TrimSpace(staleOut) != "" { - for _, id := range strings.Split(strings.TrimSpace(staleOut), "\n") { - if id != "" { - output.StaleTasks = append(output.StaleTasks, id) - } - } - } - - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(output) -} diff --git a/cmd/jjtask/cmd/parallel.go b/cmd/jjtask/cmd/parallel.go index 63d5d79..1bd876c 100644 --- a/cmd/jjtask/cmd/parallel.go +++ b/cmd/jjtask/cmd/parallel.go @@ -6,20 +6,23 @@ import ( "github.com/spf13/cobra" ) -var parallelDraft bool +var ( + parallelDraft bool + parallelParent string +) var parallelCmd = &cobra.Command{ - Use: "parallel <parent> <title1> <title2> [title3...]", + Use: "parallel <title1> <title2> [title3...] [--parent REV]", Short: "Create sibling tasks under parent", Long: `Create multiple parallel task branches from the same parent. Examples: - jjtask parallel @ "Widget A" "Widget B" "Widget C" - jjtask parallel --draft @ "Future A" "Future B"`, - Args: cobra.MinimumNArgs(3), + jjtask parallel "Widget A" "Widget B" "Widget C" + jjtask parallel --draft --parent mxyz "Future A" "Future B"`, + Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - parent := args[0] - titles := args[1:] + parent := parallelParent + titles := args flag := "todo" if parallelDraft { @@ -40,6 +43,7 @@ Examples: func init() { parallelCmd.Flags().BoolVar(¶llelDraft, "draft", false, "Create with [task:draft] flag") + parallelCmd.Flags().StringVarP(¶llelParent, "parent", "p", "@", "parent revision for all tasks") rootCmd.AddCommand(parallelCmd) - parallelCmd.ValidArgsFunction = completeRevision + _ = parallelCmd.RegisterFlagCompletionFunc("parent", completeRevision) } diff --git a/cmd/jjtask/cmd/parallel_start.go b/cmd/jjtask/cmd/parallel_start.go deleted file mode 100644 index 6f173ac..0000000 --- a/cmd/jjtask/cmd/parallel_start.go +++ /dev/null @@ -1,252 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - "time" - - "github.com/spf13/cobra" - - "jjtask/internal/parallel" -) - -var ( - parallelStartMode string - parallelStartAgents int - parallelStartNames []string - parallelStartAssignments []string -) - -var parallelStartCmd = &cobra.Command{ - Use: "parallel-start <parent-task>", - Short: "Start a parallel agent session", - Long: `Start a parallel agent session for multi-agent work. - -Modes: - shared - All agents share the same @ revision (default) - workspace - Each agent gets a separate jj workspace - -Examples: - jjtask parallel-start --mode shared --agents 2 abc - jjtask parallel-start --mode workspace --agents 3 xyz - jjtask parallel-start --names agent-api,agent-ui abc - jjtask parallel-start --assign "agent-a:src/api/**,agent-b:src/ui/**" abc`, - Args: cobra.ExactArgs(1), - RunE: runParallelStart, -} - -func init() { - parallelStartCmd.Flags().StringVar(¶llelStartMode, "mode", "shared", "Session mode: shared or workspace") - parallelStartCmd.Flags().IntVar(¶llelStartAgents, "agents", 2, "Number of agents") - parallelStartCmd.Flags().StringSliceVar(¶llelStartNames, "names", nil, "Custom agent names (comma-separated)") - parallelStartCmd.Flags().StringSliceVar(¶llelStartAssignments, "assign", nil, "Agent assignments (agent:pattern,...)") - rootCmd.AddCommand(parallelStartCmd) - parallelStartCmd.ValidArgsFunction = completeRevision -} - -func runParallelStart(cmd *cobra.Command, args []string) error { - parentRev := args[0] - - // Validate mode - if parallelStartMode != "shared" && parallelStartMode != "workspace" { - return fmt.Errorf("invalid mode %q: must be 'shared' or 'workspace'", parallelStartMode) - } - - // Generate agent names - agentNames := parallelStartNames - if len(agentNames) == 0 { - agentNames = generateAgentNames(parallelStartAgents) - } - - // Get the full change ID of parent to avoid ambiguity after creating children - fullParentID, err := client.Query("log", "-r", parentRev, "--no-graph", "-T", "change_id", "--limit", "1") - if err != nil { - return fmt.Errorf("resolve parent revision: %w", err) - } - fullParentID = strings.TrimSpace(fullParentID) - - // Get parent task description to check for existing assignments - parentDesc, err := client.GetDescription(parentRev) - if err != nil { - return fmt.Errorf("get parent description: %w", err) - } - - // Check if session already exists - existingSession, err := parallel.ParseSession(parentDesc) - if err != nil { - return fmt.Errorf("parse existing session: %w", err) - } - if existingSession != nil && len(existingSession.Agents) > 0 { - return fmt.Errorf("parallel session already exists on %s; use parallel-stop first", parentRev) - } - - // Build session - session := ¶llel.Session{ - Mode: parallelStartMode, - Started: time.Now(), - Agents: make([]parallel.Agent, len(agentNames)), - } - - // Parse assignments from flag - assignments := parseAssignments(parallelStartAssignments) - - for i, name := range agentNames { - session.Agents[i] = parallel.Agent{ - ID: name, - FilePattern: assignments[name], - Description: fmt.Sprintf("Agent %d", i+1), - } - } - - // Warn if no assignments in shared mode - if parallelStartMode == "shared" { - hasAssignments := false - for _, a := range session.Agents { - if a.FilePattern != "" { - hasAssignments = true - break - } - } - if !hasAssignments { - fmt.Println("Warning: No file assignments specified for shared mode") - fmt.Println("Use --assign to specify: --assign \"agent-a:src/api/**,agent-b:src/ui/**\"") - fmt.Println("Without assignments, agents may conflict by editing the same files") - fmt.Println() - } - } - - // Get repo root for workspace mode - repoRoot, err := client.Root() - if err != nil { - return fmt.Errorf("get repo root: %w", err) - } - - if parallelStartMode == "workspace" { - if err := setupWorkspaceMode(session, repoRoot, fullParentID); err != nil { - return err - } - } - - // Mark parent as wip - if err := setTaskFlag(fullParentID, "wip"); err != nil { - return fmt.Errorf("mark parent wip: %w", err) - } - - // Update parent description with session info - newDesc := parallel.UpdateDescription(parentDesc, session) - if err := client.SetDescription(fullParentID, newDesc); err != nil { - return fmt.Errorf("update parent description: %w", err) - } - - // Print output - printSessionStarted(session, repoRoot) - - return nil -} - -func setupWorkspaceMode(session *parallel.Session, repoRoot, parentRev string) error { - // Ensure .jjtask-workspaces is ignored - if err := parallel.EnsureIgnored(repoRoot); err != nil { - return fmt.Errorf("setup gitignore: %w", err) - } - - // Create child tasks and workspaces for each agent - for i := range session.Agents { - agent := &session.Agents[i] - - // Create child task - taskTitle := fmt.Sprintf("[task:wip] %s task", agent.ID) - if err := client.Run("new", "--no-edit", parentRev, "-m", taskTitle); err != nil { - return fmt.Errorf("create task for %s: %w", agent.ID, err) - } - - // Get the change ID of the new task - taskID, err := client.Query("log", "-r", fmt.Sprintf("children(%s) & description(substring:%q)", parentRev, agent.ID), "--no-graph", "-T", "change_id.shortest()", "--limit", "1") - if err != nil { - return fmt.Errorf("get task ID for %s: %w", agent.ID, err) - } - agent.TaskID = strings.TrimSpace(taskID) - - // Create workspace - _, err = parallel.CreateWorkspace(client, repoRoot, agent.ID, agent.TaskID) - if err != nil { - return fmt.Errorf("create workspace for %s: %w", agent.ID, err) - } - } - - return nil -} - -func printSessionStarted(session *parallel.Session, _ string) { - fmt.Printf("Parallel session started (mode: %s)\n\n", session.Mode) - - for _, agent := range session.Agents { - fmt.Printf("Agent: %s\n", agent.ID) - if session.Mode == "workspace" { - fmt.Printf(" Workspace: %s/%s\n", parallel.WorkspacesDir, agent.ID) - fmt.Printf(" Task: %s\n", agent.TaskID) - } - if agent.FilePattern != "" { - fmt.Printf(" Assignment: %s\n", agent.FilePattern) - } - fmt.Println() - } - - fmt.Println("To get agent context: jjtask agent-context <agent-id>") - if session.Mode == "shared" { - fmt.Println("Note: Agents share @ - coordinate file assignments before starting") - } -} - -func generateAgentNames(n int) []string { - names := make([]string, n) - for i := range n { - names[i] = fmt.Sprintf("agent-%c", 'a'+i) - } - return names -} - -func setTaskFlag(rev, flag string) error { - desc, err := client.GetDescription(rev) - if err != nil { - return err - } - - // Replace [task:*] with [task:flag] - lines := strings.Split(desc, "\n") - if len(lines) > 0 { - lines[0] = replaceTaskFlag(lines[0], flag) - } - return client.SetDescription(rev, strings.Join(lines, "\n")) -} - -func replaceTaskFlag(line, newFlag string) string { - // Match [task:*] pattern - start := strings.Index(line, "[task:") - if start == -1 { - return line - } - end := strings.Index(line[start:], "]") - if end == -1 { - return line - } - return line[:start] + "[task:" + newFlag + "]" + line[start+end+1:] -} - -// parseAssignments parses "agent:pattern,agent:pattern" format -func parseAssignments(assignments []string) map[string]string { - result := make(map[string]string) - for _, a := range assignments { - // Handle both comma-separated in one string and multiple flags - parts := strings.Split(a, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if idx := strings.Index(part, ":"); idx > 0 { - agentID := strings.TrimSpace(part[:idx]) - pattern := strings.TrimSpace(part[idx+1:]) - result[agentID] = pattern - } - } - } - return result -} diff --git a/cmd/jjtask/cmd/parallel_status.go b/cmd/jjtask/cmd/parallel_status.go deleted file mode 100644 index 2be3622..0000000 --- a/cmd/jjtask/cmd/parallel_status.go +++ /dev/null @@ -1,213 +0,0 @@ -package cmd - -import ( - "fmt" - "regexp" - "strings" - "time" - - "github.com/spf13/cobra" - - "jjtask/internal/parallel" -) - -var parallelStatusCmd = &cobra.Command{ - Use: "parallel-status [parent-task]", - Short: "Show status of parallel agent session", - Long: `View status of all agents in a parallel session. - -Shows each agent's progress, file changes, and potential conflicts. - -Examples: - jjtask parallel-status # auto-detect from current context - jjtask parallel-status abc # explicit parent task`, - Args: cobra.MaximumNArgs(1), - RunE: runParallelStatus, -} - -func init() { - rootCmd.AddCommand(parallelStatusCmd) - parallelStatusCmd.ValidArgsFunction = completeRevision -} - -type agentStatus struct { - ID string - TaskID string - Flag string - FilesChanged int - LinesAdded int - LinesRemoved int -} - -func runParallelStatus(cmd *cobra.Command, args []string) error { - var session *parallel.Session - var parentRev, parentDesc string - var err error - - if len(args) > 0 { - parentRev = args[0] - parentDesc, err = client.GetDescription(parentRev) - if err != nil { - return fmt.Errorf("get description: %w", err) - } - session, err = parallel.ParseSession(parentDesc) - if err != nil { - return fmt.Errorf("parse session from %s: %w", parentRev, err) - } - } else { - session, parentRev, parentDesc, err = findParallelSession() - if err != nil { - return err - } - } - - if session == nil { - return fmt.Errorf("no parallel session found") - } - - // Get parent title - parentTitle := "" - lines := strings.Split(parentDesc, "\n") - if len(lines) > 0 { - parentTitle = strings.TrimSpace(lines[0]) - } - - // Print header - fmt.Printf("Parallel Session: %s %s\n\n", parentRev, parentTitle) - fmt.Printf("Mode: %s\n", session.Mode) - if !session.Started.IsZero() { - fmt.Printf("Started: %s ago\n", formatDuration(time.Since(session.Started))) - } - fmt.Println() - - // Collect agent statuses - var statuses []agentStatus - for _, agent := range session.Agents { - status := agentStatus{ - ID: agent.ID, - TaskID: agent.TaskID, - } - - // Get task flag and file stats - if session.Mode == "workspace" && agent.TaskID != "" { - status.Flag = getTaskFlag(agent.TaskID) - status.FilesChanged, status.LinesAdded, status.LinesRemoved = getFileStats(agent.TaskID) - } else { - status.Flag = getTaskFlag(parentRev) - // For shared mode, would need to filter by file pattern - } - - statuses = append(statuses, status) - } - - // Print status table - fmt.Printf("%-12s %-10s %-12s %s\n", "Agent", "Status", "Task", "Changes") - fmt.Printf("%-12s %-10s %-12s %s\n", "-----", "------", "----", "-------") - for _, s := range statuses { - task := s.TaskID - if task == "" { - task = "(shared)" - } - changes := fmt.Sprintf("%d files (+%d/-%d)", s.FilesChanged, s.LinesAdded, s.LinesRemoved) - if s.FilesChanged == 0 { - changes = "no changes" - } - fmt.Printf("%-12s %-10s %-12s %s\n", s.ID, s.Flag, task, changes) - } - fmt.Println() - - // Check for conflicts - if session.Mode == "workspace" { - fileConflicts, err := parallel.FindFileConflicts(client, session) - if err != nil { - fmt.Printf("Warning: could not check conflicts: %v\n", err) - } - if len(fileConflicts) > 0 { - fmt.Println("File Conflicts:") - for _, c := range fileConflicts { - fmt.Printf(" %s modified by: %s\n", c.File, strings.Join(c.Agents, ", ")) - } - } else { - fmt.Println("File Conflicts: none") - } - fmt.Println() - } else { - // Check pattern overlaps for shared mode - warnings := parallel.CheckPatternOverlaps(session) - if len(warnings) > 0 { - fmt.Println("Pattern Overlap Warnings:") - for _, w := range warnings { - fmt.Printf(" - %s\n", w) - } - fmt.Println() - } - } - - // Show DAG - dagOutput, err := client.Query("log", "-r", fmt.Sprintf("(%s):: & tasks()", parentRev)) - if err == nil && strings.TrimSpace(dagOutput) != "" { - fmt.Println("DAG:") - fmt.Println(dagOutput) - } - - return nil -} - -func getTaskFlag(rev string) string { - desc, err := client.GetDescription(rev) - if err != nil { - return "unknown" - } - lines := strings.Split(desc, "\n") - if len(lines) == 0 { - return "none" - } - // Extract [task:FLAG] - re := regexp.MustCompile(`\[task:(\w+)\]`) - match := re.FindStringSubmatch(lines[0]) - if match != nil { - return match[1] - } - return "none" -} - -func getFileStats(rev string) (files, added, removed int) { - out, err := client.Query("diff", "-r", rev, "--stat") - if err != nil || strings.TrimSpace(out) == "" { - return 0, 0, 0 - } - - // Parse stat output - last line usually has summary - lines := strings.Split(strings.TrimSpace(out), "\n") - for _, line := range lines { - if strings.Contains(line, "file") { - // Match patterns like "3 files changed, 120 insertions(+), 5 deletions(-)" - re := regexp.MustCompile(`(\d+) file`) - if m := re.FindStringSubmatch(line); m != nil { - _, _ = fmt.Sscanf(m[1], "%d", &files) - } - } - if strings.Contains(line, "|") { - files++ - // Count +/- from individual file lines - plusCount := strings.Count(line, "+") - minusCount := strings.Count(line, "-") - 1 // minus one for the separator - added += plusCount - removed += minusCount - } - } - return -} - -func formatDuration(d time.Duration) string { - if d < time.Minute { - return "just now" - } - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - if d < 24*time.Hour { - return fmt.Sprintf("%dh", int(d.Hours())) - } - return fmt.Sprintf("%dd", int(d.Hours()/24)) -} diff --git a/cmd/jjtask/cmd/parallel_stop.go b/cmd/jjtask/cmd/parallel_stop.go deleted file mode 100644 index 8ef3c2f..0000000 --- a/cmd/jjtask/cmd/parallel_stop.go +++ /dev/null @@ -1,162 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - - "jjtask/internal/parallel" -) - -var ( - parallelStopMerge bool - parallelStopForce bool - parallelStopKeepWorkspaces bool -) - -var parallelStopCmd = &cobra.Command{ - Use: "parallel-stop [parent-task]", - Short: "Stop a parallel agent session", - Long: `Clean up a parallel session - optionally merge work and remove workspaces. - -Examples: - jjtask parallel-stop # cleanup only - jjtask parallel-stop --merge # merge all done agents into parent - jjtask parallel-stop --force # cleanup even if agents not done`, - Args: cobra.MaximumNArgs(1), - RunE: runParallelStop, -} - -func init() { - parallelStopCmd.Flags().BoolVar(¶llelStopMerge, "merge", false, "Merge completed agent work into parent") - parallelStopCmd.Flags().BoolVar(¶llelStopForce, "force", false, "Stop even if agents are not done") - parallelStopCmd.Flags().BoolVar(¶llelStopKeepWorkspaces, "keep-workspaces", false, "Don't remove workspace directories") - rootCmd.AddCommand(parallelStopCmd) - parallelStopCmd.ValidArgsFunction = completeRevision -} - -func runParallelStop(cmd *cobra.Command, args []string) error { - var session *parallel.Session - var parentRev, parentDesc string - var err error - - if len(args) > 0 { - parentRev = args[0] - parentDesc, err = client.GetDescription(parentRev) - if err != nil { - return fmt.Errorf("get description: %w", err) - } - session, err = parallel.ParseSession(parentDesc) - if err != nil { - return fmt.Errorf("parse session from %s: %w", parentRev, err) - } - } else { - session, parentRev, parentDesc, err = findParallelSession() - if err != nil { - return err - } - } - - if session == nil { - return fmt.Errorf("no parallel session found") - } - - repoRoot, err := client.Root() - if err != nil { - return fmt.Errorf("get repo root: %w", err) - } - - // Check agent completion status - var incomplete []string - var doneAgents []parallel.Agent - for _, agent := range session.Agents { - var flag string - if session.Mode == "workspace" && agent.TaskID != "" { - flag = getTaskFlag(agent.TaskID) - } else { - flag = getTaskFlag(parentRev) - } - - if flag == "done" { - doneAgents = append(doneAgents, agent) - } else { - incomplete = append(incomplete, fmt.Sprintf("%s (%s)", agent.ID, flag)) - } - } - - if len(incomplete) > 0 && !parallelStopForce { - return fmt.Errorf("agents not done: %s\nUse --force to stop anyway", strings.Join(incomplete, ", ")) - } - - // Merge if requested - if parallelStopMerge && session.Mode == "workspace" { - fmt.Println("Merging completed work...") - for _, agent := range doneAgents { - if agent.TaskID == "" { - continue - } - fmt.Printf(" Squashing %s (%s) into %s\n", agent.ID, agent.TaskID, parentRev) - if err := client.Run("squash", "--from", agent.TaskID, "--into", parentRev); err != nil { - fmt.Printf(" Warning: failed to squash %s: %v\n", agent.ID, err) - } - } - } - - // Cleanup workspaces - if session.Mode == "workspace" && !parallelStopKeepWorkspaces { - fmt.Println("Cleaning up workspaces...") - for _, agent := range session.Agents { - fmt.Printf(" Removing %s\n", agent.ID) - if err := parallel.CleanupWorkspace(client, repoRoot, agent.ID); err != nil { - fmt.Printf(" Warning: %v\n", err) - } - } - } - - // Remove parallel session from description - newDesc := removeParallelSession(parentDesc) - if err := client.SetDescription(parentRev, newDesc); err != nil { - return fmt.Errorf("update description: %w", err) - } - - // Mark parent done if all complete - if len(incomplete) == 0 { - if err := setTaskFlag(parentRev, "done"); err != nil { - fmt.Printf("Warning: failed to mark parent done: %v\n", err) - } else { - fmt.Printf("Marked %s as done\n", parentRev) - } - } - - fmt.Println("Parallel session stopped") - return nil -} - -func removeParallelSession(desc string) string { - lines := strings.Split(desc, "\n") - var result []string - var inParallelSection bool - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "## Parallel Session") { - inParallelSection = true - continue - } - - if inParallelSection { - if strings.HasPrefix(trimmed, "## ") { - inParallelSection = false - result = append(result, line) - } - continue - } - - result = append(result, line) - } - - // Clean up extra blank lines - return strings.TrimSpace(strings.Join(result, "\n")) + "\n" -} diff --git a/cmd/jjtask/cmd/prime.go b/cmd/jjtask/cmd/prime.go index 50a228e..a1a6b56 100644 --- a/cmd/jjtask/cmd/prime.go +++ b/cmd/jjtask/cmd/prime.go @@ -1,26 +1,110 @@ package cmd import ( + "context" + "encoding/json" "fmt" + "io" "os" - "path/filepath" "strings" + "time" "github.com/spf13/cobra" - "jjtask/internal/parallel" + "jjtask/internal/config" "jjtask/internal/workspace" ) +// hookEvent represents the hook event name from Claude Code +type hookEvent string + +const ( + hookEventSessionStart hookEvent = "SessionStart" + hookEventPreCompact hookEvent = "PreCompact" +) + +// hookPayload represents the JSON payload from Claude Code hooks +type hookPayload struct { + HookEventName string `json:"hook_event_name"` + Trigger string `json:"trigger"` // "manual" or "auto" for PreCompact + Source string `json:"source"` // "startup", "resume", "clear", "compact" for SessionStart +} + +// detectHookEvent detects which Claude Code hook triggered this invocation +func detectHookEvent() (event hookEvent, trigger string) { + // Claude Code passes payload as JSON to stdin + // Check if stdin is a pipe (not TTY) + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + // TTY - no hook data + return hookEventSessionStart, "" + } + + // Read stdin with timeout to avoid blocking on empty pipe + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + dataCh := make(chan []byte, 1) + go func() { + data, _ := io.ReadAll(os.Stdin) + dataCh <- data + }() + + select { + case data := <-dataCh: + if len(data) == 0 { + return hookEventSessionStart, "" + } + var p hookPayload + if err := json.Unmarshal(data, &p); err == nil { + switch p.HookEventName { + case "PreCompact": + return hookEventPreCompact, p.Trigger + case "SessionStart": + return hookEventSessionStart, p.Source + } + } + case <-ctx.Done(): + // Timeout - no data available + } + + return hookEventSessionStart, "" +} + var primeCmd = &cobra.Command{ Use: "prime", Short: "Output session context for hooks", Long: `Output current task context for use in hooks or prompts. -This is typically used by SessionStart hooks to provide context -about pending tasks to AI assistants.`, +This is typically used by SessionStart and PreCompact hooks to provide +context about pending tasks to AI assistants. + +For PreCompact (auto), outputs task state verification instructions. +For SessionStart, outputs full quick reference.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + event, trigger := detectHookEvent() + + // PreCompact auto = context nearly full, output verification prompt + if event == hookEventPreCompact && trigger == "auto" { + return printPreCompactContext() + } + + // Check for custom prime content + customContent, hasCustom, err := config.GetPrimeContent() + if err != nil { + return fmt.Errorf("reading prime config: %w", err) + } + if hasCustom { + fmt.Println() + fmt.Print(customContent) + if !strings.HasSuffix(customContent, "\n") { + fmt.Println() + } + printCurrentTasks() + return nil + } + fmt.Println() fmt.Println("## JJ TASK Quick Reference") fmt.Println() @@ -32,162 +116,330 @@ about pending tasks to AI assistants.`, fmt.Println() fmt.Println("### Commands (all support -R, --quiet, etc.)") - fmt.Println("jjtask find [FLAG] [-r REVSET] List tasks (flags: todo/wip/done/all, -r for revset)") - fmt.Println("jjtask create PARENT TITLE [DESC] Create task child of PARENT (required)") - fmt.Println("jjtask flag REV FLAG Change task flag") - fmt.Println("jjtask next [--mark-as FLAG] [REV] Review current specs, optionally transition") - fmt.Println("jjtask hoist Rebase pending tasks to children of @") - fmt.Println("jjtask finalize [REV] Strip [task:*] prefix for final commit") - fmt.Println("jjtask parallel PARENT T1 T2... Create sibling tasks under PARENT") - fmt.Println("jjtask show-desc [REV] Print revision description") - fmt.Println("jjtask desc-transform REV SED_EXPR Transform description with sed") - fmt.Println("jjtask batch-desc SED_EXPR REVSET Transform multiple descriptions") - fmt.Println("jjtask checkpoint [MSG] Create checkpoint commit") + fmt.Println("jjtask create TITLE [-p REV] Create task as child of @ (or REV)") + fmt.Println("jjtask wip [TASKS...] Mark WIP, add as parents of @") + fmt.Println("jjtask done [TASKS...] Mark done, linearize into ancestry") + fmt.Println("jjtask drop TASKS... [--abandon] Remove from @ (standby or abandon)") + fmt.Println("jjtask squash Flatten @ merge for push") + fmt.Println("jjtask find [-s STATUS] [-r REVSET] List tasks (status: todo/wip/done/all)") + fmt.Println("jjtask flag STATUS [-r REV] Change task flag (defaults to @)") + fmt.Println("jjtask parallel T1 T2... [-p REV] Create sibling tasks (defaults to @)") + fmt.Println("jjtask show-desc [-r REV] Print revision description") + fmt.Println("jjtask desc-transform CMD [-r REV] Transform description with command") + fmt.Println("jjtask checkpoint [-m MSG] Create checkpoint commit") + fmt.Println("jjtask stale Find done tasks not in @'s ancestry") fmt.Println("jjtask all CMD [ARGS] Run jj CMD across workspaces") fmt.Println() fmt.Println("### Workflow") - fmt.Println("1. `/jjtask` - load skill for full workflow docs") - fmt.Println("2. `jjtask find` - see task DAG (DAG order = priority)") - fmt.Println("3. `jjtask show-desc REV` - read FULL spec before starting") - fmt.Println("4. `jj edit REV && jjtask flag @ wip` - start work") - fmt.Println("5. `jjtask hoist` - after commits, rebase tasks to stay children of @") - fmt.Println("6. `jjtask next --mark-as done NEXT` - only when ALL criteria met") + fmt.Println("1. `jjtask create 'task'` # Plan tasks") + fmt.Println("2. `jjtask wip TASK` # Start (single=edit, multi=merge)") + fmt.Println("3. `jj edit TASK` to work # Work directly in task branch") + fmt.Println("4. `jjtask done` # Complete, linearizes into ancestry") + fmt.Println("5. `jjtask squash` # Flatten for push") + fmt.Println() + fmt.Println("Key: @ is merge of WIP tasks. Work in task branches directly.") + fmt.Println("For merge: `jj edit TASK`, not bare `jj absorb`.") fmt.Println() fmt.Println("### Rules") fmt.Println("- DAG = priority: parent tasks complete before children") - fmt.Println("- Chain related tasks: `jjtask create PREV_TASK 'Next step'`") + fmt.Println("- Chain related tasks: `jjtask create --chain 'Next step'`") fmt.Println("- Read full spec before editing - descriptions are specifications") fmt.Println("- Never mark done unless ALL acceptance criteria pass") - fmt.Println("- Use --mark-as review/blocked/untested if incomplete") - fmt.Println("- `jjtask hoist` keeps task DAG connected to current work") + fmt.Println("- Use `jjtask flag review/blocked/untested` if incomplete") fmt.Println("- Stop and report if unsure - don't attempt JJ recovery ops") fmt.Println() + fmt.Println("### Before Saying Done") + fmt.Println("[ ] All acceptance criteria in task spec pass") + fmt.Println("[ ] `jjtask done TASK` - mark complete") + fmt.Println("[ ] `jjtask squash` - flatten for push when ready") + fmt.Println() + fmt.Println("### Native Task Tools") fmt.Println("TaskCreate, TaskUpdate, TaskList, TaskGet - for session workflow tracking") fmt.Println("Use for: multi-step work within a session, dependency ordering, progress display") fmt.Println("jjtask = persistent tasks in repo history; Task* = ephemeral session tracking") fmt.Println() - // Check for parallel session context - printParallelContext() - fmt.Println("### Current Tasks") + fmt.Println() repos, workspaceRoot, _ := workspace.GetRepos() - isMulti := len(repos) > 1 + hasTasks := false for _, repo := range repos { repoPath := workspace.ResolveRepoPath(repo, workspaceRoot) - - if isMulti { - fmt.Printf("--- %s ---\n", workspace.DisplayName(repo)) - } - - // Show pending tasks (include @ only if it's a task) - out, err := client.Query("-R", repoPath, "log", "--no-graph", "-r", "tasks_pending() | (@ & tasks())", "-T", "task_log_flat") - if err == nil { - outStr := strings.TrimRight(out, "\n") - if outStr != "" { - fmt.Println(outStr) - } - } - - if isMulti { - fmt.Println() + if printCompactTasks(repoPath, len(repos) > 1, workspace.DisplayName(repo)) { + hasTasks = true } } + if !hasTasks { + fmt.Println("No tasks. Create one with: jjtask create 'Task title'") + } + + fmt.Println() + printCompactChanges() return nil }, } -func printParallelContext() { - // Detection strategies: - // 1. JJTASK_AGENT_ID env var (explicit) - // 2. In .jjtask-workspaces/<agent-id>/ directory - // 3. Current task has parallel session markers - - agentID := os.Getenv("JJTASK_AGENT_ID") - - // Check if in workspace directory - if agentID == "" { - cwd, _ := os.Getwd() - if strings.Contains(cwd, parallel.WorkspacesDir) { - // Extract agent ID from path - parts := strings.Split(cwd, parallel.WorkspacesDir+string(filepath.Separator)) - if len(parts) > 1 { - // Get first path component after .jjtask-workspaces/ - agentPath := strings.Split(parts[1], string(filepath.Separator)) - if len(agentPath) > 0 && agentPath[0] != "" { - agentID = agentPath[0] - } +// printCurrentTasks outputs the current tasks section +func printCurrentTasks() { + fmt.Println() + fmt.Println("### Current Tasks") + fmt.Println() + + repos, workspaceRoot, _ := workspace.GetRepos() + + hasTasks := false + for _, repo := range repos { + repoPath := workspace.ResolveRepoPath(repo, workspaceRoot) + if printCompactTasks(repoPath, len(repos) > 1, workspace.DisplayName(repo)) { + hasTasks = true + } + } + if !hasTasks { + fmt.Println("No tasks. Create one with: jjtask create 'Task title'") + } +} + +// taskSection holds tasks for one status category +type taskSection struct { + header string + lines []string +} + +// printCompactTasks outputs tasks in compact format: id | title, aligned across all sections +// Returns true if any tasks were printed +func printCompactTasks(repoPath string, isMulti bool, repoName string) bool { + if isMulti { + fmt.Printf("--- %s ---\n", repoName) + } + + // Collect all sections + sections := []taskSection{ + {"WIP", queryTaskLines(repoPath, "tasks_wip()", "[task:wip] ")}, + {"Todo", queryTaskLines(repoPath, "tasks_todo()", "[task:todo] ")}, + {"Draft", queryTaskLines(repoPath, "tasks_draft()", "[task:draft] ")}, + } + + // Find max ID length across all sections + maxIDLen := 0 + for _, sec := range sections { + for _, line := range sec.lines { + if idx := strings.Index(line, " | "); idx > maxIDLen { + maxIDLen = idx } } } - // Find parallel session - session, parentRev, parentDesc, err := findParallelSession() - if err != nil || session == nil { - return + // Print each section with aligned columns + printed := false + first := true + for _, sec := range sections { + if len(sec.lines) == 0 { + continue + } + if !first { + fmt.Println() + } + first = false + printed = true + fmt.Println(sec.header + ":") + for _, line := range sec.lines { + if idx := strings.Index(line, " | "); idx > 0 { + id := line[:idx] + title := line[idx+3:] // skip " | " + fmt.Printf("%-*s %s\n", maxIDLen, id, title) + } + } } - // Get parent title - parentTitle := "" - lines := strings.Split(parentDesc, "\n") - if len(lines) > 0 { - parentTitle = strings.TrimSpace(lines[0]) + if isMulti { + fmt.Println() } + return printed +} - fmt.Println("### Parallel Session Active") - fmt.Println() - fmt.Printf("Mode: %s\n", session.Mode) - fmt.Printf("Parent: %s %s\n", parentRev, parentTitle) +// queryTaskLines queries tasks and returns lines as slice +func queryTaskLines(repoPath, revset, prefix string) []string { + tmpl := `change_id.shortest() ++ " | " ++ description.first_line().remove_prefix("` + prefix + `") ++ if(has_spec, " [desc:" ++ desc_lines ++ "L]", "") ++ "\n"` + out, _ := client.Query("-R", repoPath, "log", "--no-graph", "-r", revset, "-T", tmpl) + out = strings.TrimSpace(out) + if out == "" { + return nil + } + var lines []string + for line := range strings.SplitSeq(out, "\n") { + if line != "" { + lines = append(lines, line) + } + } + return lines +} - if agentID != "" { - fmt.Printf("Agent: %s\n", agentID) +// repoChanges holds changes for one repo +type repoChanges struct { + name string + files int + adds int + dels int + detail string +} - agent := session.GetAgentByID(agentID) - if agent != nil && agent.FilePattern != "" { - fmt.Printf("Your assignment: %s\n", agent.FilePattern) +// printCompactChanges outputs jj diff stat in compact format across all repos +func printCompactChanges() { + repos, workspaceRoot, _ := workspace.GetRepos() + isMulti := len(repos) > 1 + + totalFiles := 0 + totalAdds := 0 + totalDels := 0 + var repoResults []repoChanges + + for _, repo := range repos { + repoPath := workspace.ResolveRepoPath(repo, workspaceRoot) + out, err := client.Query("-R", repoPath, "diff", "--stat") + if err != nil || strings.TrimSpace(out) == "" { + continue + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) == 0 { + continue } - // Show files to avoid - others := session.OtherAgents(agentID) - if len(others) > 0 { - var avoidPatterns []string - for _, o := range others { - if o.FilePattern != "" { - avoidPatterns = append(avoidPatterns, o.FilePattern) - } + // Last line is summary like "5 files changed, 120 insertions(+), 45 deletions(-)" + summary := lines[len(lines)-1] + fileLines := lines[:len(lines)-1] + + // Parse summary + summaryClean := strings.ReplaceAll(summary, " changed", "") + summaryClean = strings.ReplaceAll(summaryClean, " insertions(+)", "") + summaryClean = strings.ReplaceAll(summaryClean, " insertion(+)", "") + summaryClean = strings.ReplaceAll(summaryClean, " deletions(-)", "") + summaryClean = strings.ReplaceAll(summaryClean, " deletion(-)", "") + summaryClean = strings.ReplaceAll(summaryClean, ",", "") + parts := strings.Fields(summaryClean) + + rc := repoChanges{name: workspace.DisplayName(repo)} + if len(parts) >= 2 { + _, _ = fmt.Sscanf(parts[0], "%d", &rc.files) + totalFiles += rc.files + } + if len(parts) >= 3 { + _, _ = fmt.Sscanf(parts[2], "%d", &rc.adds) + totalAdds += rc.adds + } + if len(parts) >= 4 { + _, _ = fmt.Sscanf(parts[3], "%d", &rc.dels) + totalDels += rc.dels + } + + // Collect compact file list + var compactFiles []string + for _, line := range fileLines { + fileParts := strings.Split(line, "|") + if len(fileParts) != 2 { + continue } - if len(avoidPatterns) > 0 { - fmt.Printf("Avoid: %s\n", strings.Join(avoidPatterns, ", ")) + filePath := strings.TrimSpace(fileParts[0]) + fileName := filePath[strings.LastIndex(filePath, "/")+1:] + + statPart := strings.TrimSpace(fileParts[1]) + statFields := strings.Fields(statPart) + if len(statFields) == 0 { + continue + } + + adds := strings.Count(statPart, "+") + dels := strings.Count(statPart, "-") + + stat := "" + if adds > 0 { + stat += fmt.Sprintf("+%d", adds) + } + if dels > 0 { + stat += fmt.Sprintf("-%d", dels) + } + if stat != "" { + compactFiles = append(compactFiles, fmt.Sprintf("%s %s", fileName, stat)) + } else { + compactFiles = append(compactFiles, fileName) } } + rc.detail = strings.Join(compactFiles, " | ") + repoResults = append(repoResults, rc) } - // Show other agents status - fmt.Println() - fmt.Println("Agents:") - for _, a := range session.Agents { - flag := "?" - if session.Mode == "workspace" && a.TaskID != "" { - flag = getTaskFlag(a.TaskID) - } - marker := "" - if a.ID == agentID { - marker = " ← you" + if totalFiles == 0 { + fmt.Println("### Changes (0 files +0 -0)") + return + } + + // Build header with totals + fmt.Printf("### Changes (%d files +%d -%d)\n", totalFiles, totalAdds, totalDels) + + // Output per-repo if multi-repo, otherwise just file list + if isMulti { + for _, rc := range repoResults { + if rc.files == 0 { + continue + } + fmt.Printf("--- %s ---\n", rc.name) + fmt.Println(rc.detail) } - pattern := "" - if a.FilePattern != "" { - pattern = fmt.Sprintf(" (%s)", a.FilePattern) + } else if len(repoResults) > 0 && repoResults[0].detail != "" { + fmt.Println(repoResults[0].detail) + } +} + +// printPreCompactContext outputs task verification when context is nearly full +func printPreCompactContext() error { + fmt.Println() + fmt.Println("## 🚨 Context Compacting - Verify Task State") + fmt.Println() + fmt.Println("Context window nearly full. Before compaction, verify:") + fmt.Println() + fmt.Println("### Current WIP Tasks") + + repos, workspaceRoot, _ := workspace.GetRepos() + hasWIP := false + + for _, repo := range repos { + repoPath := workspace.ResolveRepoPath(repo, workspaceRoot) + out, err := client.Query("-R", repoPath, "log", "--no-graph", "-r", "tasks_wip()", "-T", "task_log_flat") + if err == nil { + outStr := strings.TrimRight(out, "\n") + if outStr != "" { + hasWIP = true + fmt.Println(outStr) + } } - fmt.Printf("- %s: [%s]%s%s\n", a.ID, flag, pattern, marker) } + + if !hasWIP { + fmt.Println("(no WIP tasks)") + } + + fmt.Println() + fmt.Println("### Verification Checklist") + fmt.Println("[ ] WIP tasks still accurate? Update status if needed") + fmt.Println("[ ] Any completed work not marked done?") + fmt.Println("[ ] Need to create follow-up tasks before context lost?") fmt.Println() + fmt.Println("### Actions") + fmt.Println("- `jjtask find wip` - review all WIP tasks") + fmt.Println("- `jjtask done TASK` - mark completed work") + fmt.Println("- `jjtask create 'Follow-up'` - capture discovered work") + fmt.Println() + fmt.Println("Confirm with user if task state needs updates before proceeding.") + fmt.Println() + + return nil } func init() { diff --git a/cmd/jjtask/cmd/show_desc.go b/cmd/jjtask/cmd/show_desc.go index ae5f035..39fc162 100644 --- a/cmd/jjtask/cmd/show_desc.go +++ b/cmd/jjtask/cmd/show_desc.go @@ -10,7 +10,10 @@ import ( "github.com/spf13/cobra" ) -var showDescFormat string +var ( + showDescFormat string + showDescRev string +) type ShowDescOutput struct { Revision string `json:"revision"` @@ -21,17 +24,18 @@ type ShowDescOutput struct { } var showDescCmd = &cobra.Command{ - Use: "show-desc [rev]", + Use: "show-desc [REV]", Short: "Print revision description", Long: `Print the description for a revision (default @). Examples: jjtask show-desc - jjtask show-desc mxyz`, + jjtask show-desc mxyz + jjtask show-desc -r mxyz`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - rev := "@" - if len(args) == 1 { + rev := showDescRev + if len(args) > 0 { rev = args[0] } @@ -76,7 +80,8 @@ Examples: } func init() { + showDescCmd.Flags().StringVarP(&showDescRev, "rev", "r", "@", "revision to show") showDescCmd.Flags().StringVar(&showDescFormat, "format", "text", "Output format: text or json") rootCmd.AddCommand(showDescCmd) - showDescCmd.ValidArgsFunction = completeRevision + _ = showDescCmd.RegisterFlagCompletionFunc("rev", completeRevision) } diff --git a/cmd/jjtask/cmd/squash.go b/cmd/jjtask/cmd/squash.go new file mode 100644 index 0000000..658598c --- /dev/null +++ b/cmd/jjtask/cmd/squash.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var squashKeepTasks bool + +var squashCmd = &cobra.Command{ + Use: "squash", + Short: "Flatten @ merge into linear commit", + Long: `Flatten the current @ merge into a single linear commit. + +This takes all the merged task commits and squashes them into one commit, +ready for pushing. The commit message combines descriptions from all tasks. + +Examples: + jjtask squash # Flatten everything + jjtask squash --keep-tasks # Keep task revisions after squash`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // Get parents of @ (the merged tasks) + parentsOut, err := client.Query("log", "-r", "parents(@)", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + if err != nil { + return fmt.Errorf("failed to get parents: %w", err) + } + + var parents []string + for _, line := range strings.Split(strings.TrimSpace(parentsOut), "\n") { + if line != "" { + parents = append(parents, line) + } + } + + if len(parents) == 0 { + fmt.Println("No parents to squash") + return nil + } + + if len(parents) == 1 { + fmt.Println("Only one parent, nothing to merge-squash") + return nil + } + + // Build combined commit message from task descriptions + var msgParts []string + for _, p := range parents { + desc, err := client.GetDescription(p) + if err != nil || desc == "" { + continue + } + // Strip [task:*] prefix for cleaner message + desc = strings.TrimSpace(desc) + if strings.HasPrefix(desc, "[task:") { + if idx := strings.Index(desc, "]"); idx != -1 { + desc = strings.TrimSpace(desc[idx+1:]) + } + } + if desc != "" { + msgParts = append(msgParts, "- "+strings.Split(desc, "\n")[0]) + } + } + + combinedMsg := "Squashed tasks:\n" + strings.Join(msgParts, "\n") + + // Squash all parents into @ + if err := client.Run("squash", "--from", "parents(@)", "--message", combinedMsg); err != nil { + return fmt.Errorf("failed to squash: %w", err) + } + + fmt.Printf("Squashed %d tasks into linear commit\n", len(parents)) + + if !squashKeepTasks { + // Mark original tasks as done (they're now empty after squash) + for _, p := range parents { + desc, err := client.GetDescription(p) + if err != nil { + continue + } + if strings.Contains(desc, "[task:wip]") { + newDesc := strings.Replace(desc, "[task:wip]", "[task:done]", 1) + _ = client.SetDescription(p, newDesc) + } + } + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(squashCmd) + squashCmd.Flags().BoolVar(&squashKeepTasks, "keep-tasks", false, "Keep task revisions after squash") +} diff --git a/cmd/jjtask/cmd/stale.go b/cmd/jjtask/cmd/stale.go new file mode 100644 index 0000000..f911b89 --- /dev/null +++ b/cmd/jjtask/cmd/stale.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var staleCmd = &cobra.Command{ + Use: "stale", + Short: "Find done tasks not in current line of work", + Long: `Find done tasks that are not ancestors of @. + +These are "stale" branches - work that was completed on a side branch +but may not have been integrated into your current work. They might be: +- Superseded (work landed via different commits) +- Orphaned (forgot to merge) +- Exploratory (intentionally abandoned) + +Use jj abandon to clean up superseded/orphaned tasks.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // Find done tasks not in @'s ancestry + out, err := client.Query("log", "-r", "tasks_done() ~ ::@", "--no-graph", "-T", `change_id.shortest() ++ " " ++ description.first_line() ++ "\n"`) + if err != nil { + return fmt.Errorf("failed to find stale tasks: %w", err) + } + + out = strings.TrimSpace(out) + if out == "" { + fmt.Println("No stale done tasks found") + return nil + } + + fmt.Println("Stale done tasks (not in @'s ancestry):") + fmt.Println() + for _, line := range strings.Split(out, "\n") { + if line != "" { + fmt.Println(" " + line) + } + } + fmt.Println() + fmt.Println("These may be superseded, orphaned, or exploratory.") + fmt.Println("Use `jj abandon REV` to clean up, or `jj rebase -s REV -d @` to integrate.") + + return nil + }, +} + +func init() { + rootCmd.AddCommand(staleCmd) +} diff --git a/cmd/jjtask/cmd/testutil_test.go b/cmd/jjtask/cmd/testutil_test.go new file mode 100644 index 0000000..a0ebaaf --- /dev/null +++ b/cmd/jjtask/cmd/testutil_test.go @@ -0,0 +1,277 @@ +package cmd_test + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Base timestamp for deterministic tests (same as jj's test suite) +var baseTimestamp = time.Date(2001, 2, 3, 4, 5, 6, 0, time.FixedZone("", 7*3600)) + +// makeBaseEnv creates the base environment for deterministic jj tests. +// Match jj's test environment for reproducible operation IDs and timestamps. +func makeBaseEnv(projectRoot, homeDir string) []string { + binPath := filepath.Join(projectRoot, "bin", "jjtask-go") + configPath := filepath.Join(projectRoot, "config") + return []string{ + "JJ_CONFIG=" + configPath, + "PATH=" + filepath.Dir(binPath) + ":" + os.Getenv("PATH"), + "JJ_USER=Test User", + "JJ_EMAIL=test.user@example.com", + "JJ_OP_HOSTNAME=host.example.com", + "JJ_OP_USERNAME=test-username", + "JJ_TZ_OFFSET_MINS=420", + "HOME=" + homeDir, + } +} + +// TestRepo provides a test jj repository with command logging +type TestRepo struct { + t *testing.T + dir string + log *bytes.Buffer + baseEnv []string + cmdCounter int + lastDAG string // for deduplicating before/after logs +} + +// SetupTestRepo creates a fresh jj repo for testing. +// Automatically saves a snapshot at test end using the test name. +func SetupTestRepo(t *testing.T) *TestRepo { + t.Helper() + + dir, err := os.MkdirTemp("", "jjtask-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + repo := &TestRepo{ + t: t, + dir: dir, + log: &bytes.Buffer{}, + cmdCounter: 0, + } + + repo.baseEnv = makeBaseEnv(repo.findProjectRoot(), dir) + + // Auto-snapshot at test end using test name + t.Cleanup(func() { + repo.autoSnapshot(t) + _ = os.RemoveAll(dir) + }) + + // Initialize jj repo (uses deterministic env) + repo.runSilent("jj", "git", "init", "--colocate") + + return repo +} + +// getEnvForCommand returns environment with deterministic seed/timestamp for this command +func (r *TestRepo) getEnvForCommand() []string { + r.cmdCounter++ + ts := baseTimestamp.Add(time.Duration(r.cmdCounter) * time.Second) + tsStr := ts.Format("2006-01-02T15:04:05-07:00") + + env := make([]string, len(r.baseEnv), len(r.baseEnv)+3) + copy(env, r.baseEnv) + env = append(env, + fmt.Sprintf("JJ_RANDOMNESS_SEED=%d", r.cmdCounter), + "JJ_TIMESTAMP="+tsStr, + "JJ_OP_TIMESTAMP="+tsStr, + ) + return env +} + +func (r *TestRepo) findProjectRoot() string { + // Walk up from test file to find project root (has go.mod) + dir, _ := os.Getwd() + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + r.t.Fatal("could not find project root") + } + dir = parent + } +} + +// Run executes a command and logs it to the trace +func (r *TestRepo) Run(name string, args ...string) string { + r.t.Helper() + return r.runInternal(name, args, true) +} + +// RunExpectFail executes a command expected to fail, logs it +func (r *TestRepo) RunExpectFail(name string, args ...string) string { + r.t.Helper() + return r.runInternal(name, args, false) +} + +// runSilent executes without logging (for setup) +func (r *TestRepo) runSilent(name string, args ...string) string { + r.t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = r.dir + cmd.Env = r.getEnvForCommand() + out, err := cmd.CombinedOutput() + if err != nil { + r.t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) + } + return string(out) +} + +func (r *TestRepo) runInternal(name string, args []string, expectSuccess bool) string { + r.t.Helper() + + // Auto-log DAG before/after jjtask commands + logDAG := name == "jjtask" + if logDAG { + r.logDAGWithLabel("before") + } + + // Use absolute path for jjtask-go to avoid PATH issues in CI + execName := name + if name == "jjtask" { + execName = filepath.Join(r.findProjectRoot(), "bin", "jjtask-go") + } + cmd := exec.Command(execName, args...) + cmd.Dir = r.dir + cmd.Env = r.getEnvForCommand() + out, err := cmd.CombinedOutput() + + // Log to trace + fmt.Fprintf(r.log, "$ %s %s\n", name, strings.Join(args, " ")) + fmt.Fprintf(r.log, "%s\n", out) + + if expectSuccess && err != nil { + r.t.Fatalf("command failed: %s %v\nerror: %v\noutput: %s", name, args, err, out) + } + if !expectSuccess && err == nil { + r.t.Fatalf("expected command to fail: %s %v\n%s", name, args, out) + } + + // Auto-log DAG after + if logDAG && expectSuccess { + r.logDAGWithLabel("after") + } + + return string(out) +} + +// logDAGWithLabel appends jj log output to the trace with a label +// Skips if "before" matches previous "after" (avoids duplicate output) +func (r *TestRepo) logDAGWithLabel(label string) { + cmd := exec.Command("jj", "log", "-r", "all()", "-T", "test_log") + cmd.Dir = r.dir + cmd.Env = r.getEnvForCommand() + out, _ := cmd.CombinedOutput() + dag := string(out) + + // Skip "before" if it matches the previous "after" + if label == "before" && dag == r.lastDAG { + return + } + + fmt.Fprintf(r.log, "# %s\n%s\n", label, dag) + r.lastDAG = dag +} + +// WriteFile creates a file in the repo +func (r *TestRepo) WriteFile(name, content string) { + r.t.Helper() + path := filepath.Join(r.dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + r.t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + r.t.Fatalf("failed to write file: %v", err) + } + fmt.Fprintf(r.log, "# wrote %s (%d bytes)\n\n", name, len(content)) +} + +// GetTaskID finds a task by flag and returns its shortest change ID +func (r *TestRepo) GetTaskID(flag string) string { + r.t.Helper() + out := r.runSilent("jj", "log", "-r", fmt.Sprintf("tasks_%s()", flag), + "--no-graph", "-T", "change_id.shortest()") + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) == 0 || lines[0] == "" { + r.t.Fatalf("no task found with flag %s", flag) + } + return lines[0] +} + +// Trace returns the full command log +func (r *TestRepo) Trace() string { + return r.log.String() +} + +// autoSnapshot is called automatically at test end via Cleanup. +// Converts TestName to snake_case for the snapshot filename. +func (r *TestRepo) autoSnapshot(t *testing.T) { + name := testNameToSnakeCase(t.Name()) + r.snapshotWithName(t, name) +} + +// Snapshot compares the command trace against a golden file (legacy API) +func (r *TestRepo) Snapshot(t *testing.T, name string) { + t.Helper() + r.snapshotWithName(t, name) +} + +func (r *TestRepo) snapshotWithName(t *testing.T, name string) { + trace := r.normalizeTrace(r.log.String()) + + snapshotDir := filepath.Join(r.findProjectRoot(), "test", "snapshots_go") + snapshotFile := filepath.Join(snapshotDir, name+".txt") + + if os.Getenv("SNAPSHOT_UPDATE") != "" { + _ = os.MkdirAll(snapshotDir, 0o755) + if err := os.WriteFile(snapshotFile, []byte(trace), 0o644); err != nil { + t.Fatalf("failed to write snapshot: %v", err) + } + return + } + + expected, err := os.ReadFile(snapshotFile) + if err != nil { + t.Fatalf("snapshot not found: %s\nRun with SNAPSHOT_UPDATE=1 to create\n\nActual output:\n%s", snapshotFile, trace) + } + + if string(expected) != trace { + t.Errorf("snapshot mismatch: %s\n\nExpected:\n%s\n\nActual:\n%s", name, expected, trace) + } +} + +// testNameToSnakeCase converts "TestFooBar" to "foo_bar" +func testNameToSnakeCase(name string) string { + name = strings.TrimPrefix(name, "Test") + // Convert CamelCase to snake_case + var result strings.Builder + for i, r := range name { + if r >= 'A' && r <= 'Z' { + if i > 0 { + result.WriteByte('_') + } + result.WriteByte(byte(r) + 32) // lowercase + } else { + result.WriteByte(byte(r)) + } + } + return result.String() +} + +// normalizeTrace replaces variable content for deterministic snapshots +func (r *TestRepo) normalizeTrace(s string) string { + // Replace temp directory paths + s = strings.ReplaceAll(s, r.dir, "$REPO") + return s +} diff --git a/cmd/jjtask/cmd/warning_test.go b/cmd/jjtask/cmd/warning_test.go new file mode 100644 index 0000000..00e2619 --- /dev/null +++ b/cmd/jjtask/cmd/warning_test.go @@ -0,0 +1,274 @@ +package cmd_test + +import ( + "strings" + "testing" +) + +func TestFlagDoneWarnsWhenAtHasDiff(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task to complete") + taskID := repo.GetTaskID("todo") + // Make changes in @ (not in task) + repo.WriteFile("workfile.txt", "work") + + output := repo.Run("jjtask", "flag", "done", "--rev", taskID) + + if !strings.Contains(output, "Working copy (@) has uncommitted changes") { + t.Error("expected warning about uncommitted changes") + } + if !strings.Contains(output, "Were any of these changes part of this task") { + t.Error("expected question about changes") + } + +} + +func TestFlagWipWarnsWhenAtHasDiff(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task to start") + taskID := repo.GetTaskID("todo") + repo.WriteFile("workfile.txt", "work") + + output := repo.Run("jjtask", "flag", "wip", "--rev", taskID) + + if !strings.Contains(output, "Working copy (@) has uncommitted changes") { + t.Error("expected warning about uncommitted changes") + } + if !strings.Contains(output, "Were any of these changes part of this task") { + t.Error("expected question about changes") + } + +} + +func TestFlagNoWarningWhenAtIsTask(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task to complete") + taskID := repo.GetTaskID("todo") + repo.Run("jj", "edit", taskID) + repo.WriteFile("workfile.txt", "work") + + output := repo.Run("jjtask", "flag", "done") + + if strings.Contains(output, "Working copy (@) has uncommitted changes") { + t.Error("should not warn when @ is the task") + } + +} + +func TestFlagNoWarningWhenClean(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task to complete") + taskID := repo.GetTaskID("todo") + + output := repo.Run("jjtask", "flag", "done", "--rev", taskID) + + if strings.Contains(output, "Working copy (@) has uncommitted changes") { + t.Error("should not warn when clean") + } + +} + +func TestFlagDoneWarnsPendingChildren(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent task") + parentID := repo.GetTaskID("todo") + repo.Run("jjtask", "create", "--parent", parentID, "Child task") + + output := repo.Run("jjtask", "flag", "done", "--rev", parentID) + + if !strings.Contains(output, "pending children") { + t.Error("expected warning about pending children") + } + if !strings.Contains(output, "Child task") { + t.Error("expected child task in warning") + } + +} + +func TestFlagDoneNoWarningChildrenDone(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent task") + parentID := repo.GetTaskID("todo") + repo.Run("jjtask", "create", "--parent", parentID, "Child task") + + // Mark child done first + output := repo.Run("jj", "log", "-r", "children("+parentID+") & tasks()", + "--no-graph", "-T", "change_id.shortest()") + childID := strings.TrimSpace(strings.Split(output, "\n")[0]) + repo.Run("jjtask", "flag", "done", "--rev", childID) + + output = repo.Run("jjtask", "flag", "done", "--rev", parentID) + + if strings.Contains(output, "pending children") { + t.Error("should not warn when children done") + } + +} + +func TestFlagWipWarnsBlockedAncestor(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent task") + parentID := repo.GetTaskID("todo") + repo.Run("jjtask", "flag", "blocked", "--rev", parentID) + repo.Run("jjtask", "create", "--parent", parentID, "Child task") + + // Find child + output := repo.Run("jj", "log", "-r", "children("+parentID+") & tasks()", + "--no-graph", "-T", "change_id.shortest()") + childID := strings.TrimSpace(strings.Split(output, "\n")[0]) + + output = repo.Run("jjtask", "flag", "wip", "--rev", childID) + + if !strings.Contains(output, "Ancestor task is blocked") { + t.Error("expected blocked ancestor warning") + } + +} + +func TestFlagWipNoWarningNotBlocked(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent task") + parentID := repo.GetTaskID("todo") + repo.Run("jjtask", "create", "--parent", parentID, "Child task") + + // Find child + output := repo.Run("jj", "log", "-r", "children("+parentID+") & tasks()", + "--no-graph", "-T", "change_id.shortest()") + childID := strings.TrimSpace(strings.Split(output, "\n")[0]) + + output = repo.Run("jjtask", "flag", "wip", "--rev", childID) + + if strings.Contains(output, "Ancestor task is blocked") { + t.Error("should not warn when ancestor not blocked") + } + +} + +func TestFlagWipWarnsExistingWip(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task A") + repo.Run("jjtask", "create", "--parent", "@", "Task B") + + // Find both tasks + taskA := repo.Run("jj", "log", "-r", "tasks() & description(substring:\"Task A\")", + "--no-graph", "-T", "change_id.shortest()") + taskA = strings.TrimSpace(taskA) + taskB := repo.Run("jj", "log", "-r", "tasks() & description(substring:\"Task B\")", + "--no-graph", "-T", "change_id.shortest()") + taskB = strings.TrimSpace(taskB) + + // Mark A as wip + repo.Run("jjtask", "flag", "wip", "--rev", taskA) + + // Try to mark B as wip + output := repo.Run("jjtask", "flag", "wip", "--rev", taskB) + + if !strings.Contains(output, "Another WIP task exists") { + t.Error("expected existing wip warning") + } + +} + +func TestFlagWipNoWarningSameChain(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent task") + parentID := repo.GetTaskID("todo") + repo.Run("jjtask", "create", "--parent", parentID, "Child task") + + // Find child + childOutput := repo.Run("jj", "log", "-r", "children("+parentID+") & tasks()", + "--no-graph", "-T", "change_id.shortest()") + childID := strings.TrimSpace(strings.Split(childOutput, "\n")[0]) + + // Mark parent as wip + repo.Run("jjtask", "flag", "wip", "--rev", parentID) + + // Mark child as wip - should NOT warn (same chain) + output := repo.Run("jjtask", "flag", "wip", "--rev", childID) + + if strings.Contains(output, "Another WIP task exists") { + t.Error("should not warn for same chain") + } + +} + +func TestFlagWipWarnsDoneAncestor(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Parent task") + parentID := repo.GetTaskID("todo") + repo.Run("jjtask", "create", "--parent", parentID, "Child task") + + // Find child + output := repo.Run("jj", "log", "-r", "children("+parentID+") & tasks()", + "--no-graph", "-T", "change_id.shortest()") + childID := strings.TrimSpace(strings.Split(output, "\n")[0]) + + // Mark parent as done + repo.Run("jjtask", "flag", "done", "--rev", parentID) + + // Try to mark child as wip - should warn about done ancestor + output = repo.Run("jjtask", "flag", "wip", "--rev", childID) + + if !strings.Contains(output, "Ancestor task is already done") { + t.Error("expected done ancestor warning") + } + if !strings.Contains(output, "Parent task") { + t.Error("expected parent task in warning") + } + +} + +func TestFlagDoneWarnsEmpty(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Empty task") + taskID := repo.GetTaskID("todo") + + output := repo.Run("jjtask", "flag", "done", "--rev", taskID) + + if !strings.Contains(output, "Task is empty") { + t.Error("expected empty task warning") + } + +} + +func TestFlagDoneNoWarningWithContent(t *testing.T) { + t.Parallel() + repo := SetupTestRepo(t) + + repo.Run("jjtask", "create", "Task with work") + taskID := repo.GetTaskID("todo") + repo.Run("jj", "edit", taskID) + repo.WriteFile("workfile.txt", "actual work") + repo.Run("jj", "status") // Trigger snapshot + + output := repo.Run("jjtask", "flag", "done") + + if strings.Contains(output, "Task is empty") { + t.Error("should not warn with content") + } + +} diff --git a/cmd/jjtask/cmd/wip.go b/cmd/jjtask/cmd/wip.go new file mode 100644 index 0000000..8bb9b37 --- /dev/null +++ b/cmd/jjtask/cmd/wip.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var wipCmd = &cobra.Command{ + Use: "wip [tasks...]", + Short: "Mark tasks as WIP and add to @ merge", + Long: `Mark tasks as WIP and add them as parents of @. + +When multiple tasks are WIP, @ becomes a merge showing their combined state. +Work directly in task branches with 'jj edit TASK'. + +Examples: + jjtask wip xyz # Mark xyz as WIP, add to @ merge + jjtask wip # Mark @ as WIP (if it's a task) + jjtask wip a b c # Mark multiple tasks as WIP`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + revs := args + if len(revs) == 0 { + revs = []string{"@"} + } + + for _, rev := range revs { + if err := markWip(rev); err != nil { + return fmt.Errorf("failed to mark %s as WIP: %w", rev, err) + } + } + + return nil + }, +} + +func markWip(rev string) error { + // Get change ID + changeID, err := client.Query("log", "-r", rev, "--no-graph", "-T", "change_id.shortest()") + if err != nil { + return fmt.Errorf("getting change ID: %w", err) + } + changeID = strings.TrimSpace(changeID) + + // Mark as WIP + if err := setTaskFlag(rev, "wip"); err != nil { + return fmt.Errorf("setting flag: %w", err) + } + + // Add to @ merge (preserves @ content) + if err := client.AddToMerge(changeID); err != nil { + return fmt.Errorf("adding to merge: %w", err) + } + + return nil +} + +func init() { + rootCmd.AddCommand(wipCmd) +} diff --git a/config/conf.d/10-jjtask.toml b/config/conf.d/10-jjtask.toml index f459436..b2c264f 100644 --- a/config/conf.d/10-jjtask.toml +++ b/config/conf.d/10-jjtask.toml @@ -64,7 +64,7 @@ separate(" ", 'desc_lines' = 'description.trim().lines().len()' 'has_spec' = 'desc_lines > 3 && description.starts_with("[task:")' -'desc_more_text' = '"+" ++ desc_lines ++ "L"' +'desc_more_text' = '"[desc:" ++ desc_lines ++ "L]"' 'parent_ids' = 'parents.map(|p| p.change_id().shortest()).join(",")' @@ -76,7 +76,7 @@ if(root, separate(" ", format_short_change_id(change_id), if(description.starts_with("[task:"), label("task " ++ task_flag, "[task:" ++ task_flag ++ "]"), ""), - task_title, + task_title, ), if(has_spec, " " ++ label("hint", desc_more_text), ""), "\n", @@ -93,18 +93,21 @@ if(root, 'task_log_flat' = ''' if(root, "", - concat( - separate(" ", - format_short_change_id(change_id), - "(" ++ parent_ids ++ ")", - if(description.starts_with("[task:"), label("task " ++ task_flag, "[task:" ++ task_flag ++ "]"), ""), - task_title, - ), - if(has_spec, " " ++ label("hint", desc_more_text), ""), - "\n", - if(description.starts_with("[task:") && task_body_content.len() > 0, - " " ++ truncate_end(120, task_body, "...") ++ "\n", - "" + label(if(current_working_copy, "working_copy"), + concat( + if(current_working_copy, "@ ", "○ "), + separate(" ", + format_short_change_id(change_id), + "(" ++ parent_ids ++ ")", + if(description.starts_with("[task:"), label("task " ++ task_flag, "[task:" ++ task_flag ++ "]"), ""), + task_title, + ), + if(has_spec, " " ++ label("hint", desc_more_text), ""), + "\n", + if(description.starts_with("[task:") && task_body_content.len() > 0, + " " ++ truncate_end(120, task_body, "...") ++ "\n", + "" + ), ), ) ) @@ -114,5 +117,18 @@ if(root, format_short_change_id(change_id) ++ " " ++ label("task " ++ task_flag, "[task:" ++ task_flag ++ "]") ++ " " ++ task_title ++ "\n" ''' +# Clean template for test snapshots (no email, no commit hash) +'test_log' = ''' +if(root, + format_root_commit(self), + concat( + format_short_change_id(change_id), + " ", + description.first_line(), + "\n", + ) +) +''' + [aliases] task = ["util", "exec", "--", "jjtask"] diff --git a/dev-setup.sh b/dev-setup.sh index 8ec2441..e7a9b7b 100755 --- a/dev-setup.sh +++ b/dev-setup.sh @@ -1,95 +1,121 @@ #!/usr/bin/env bash -# Development setup: symlink jjtask files to ~/.config/claude/ for live editing +# Development setup for jjtask # Usage: ./dev-setup.sh # -# Creates symlinks so changes in jjtask repo are immediately usable in Claude Code. -# Run ./dev-teardown.sh to restore original setup. +# Sets up: +# - CLI: symlinks jjtask to ~/.local/bin, fish completions, jj alias +# - Plugin: symlinks binary, points installed_plugins.json to local source +# +# Run ./dev-teardown.sh to restore release plugin version. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -CLAUDE_DIR="${HOME}/.config/claude" -BACKUP_DIR="${SCRIPT_DIR}/.dev-backup" +PLUGIN_SOURCE="$SCRIPT_DIR/claude-plugin" +BIN_DIR="${HOME}/.local/bin" +FISH_FUNCTIONS_DIR="${__fish_config_dir:-${XDG_CONFIG_HOME:-$HOME/.config}/fish}/functions" +PLUGINS_JSON="${HOME}/.claude/plugins/installed_plugins.json" +BACKUP_FILE="${SCRIPT_DIR}/.dev-backup/installed_plugins.json" echo "Setting up jjtask development environment..." echo "Source: $SCRIPT_DIR" -echo "Target: $CLAUDE_DIR" echo "" -# Create backup directory -mkdir -p "$BACKUP_DIR" - -# Helper to backup and symlink -link_item() { - local src="$1" - local dst="$2" - local backup_path="$BACKUP_DIR/$(basename "$dst")" - +symlink() { + local src="$1" dst="$2" if [[ -L "$dst" ]]; then - # Already a symlink, remove it + local current=$(readlink "$dst") + if [[ "$current" == "$src" ]]; then + echo " Already linked: $(basename "$dst")" + return 0 + fi rm "$dst" elif [[ -e "$dst" ]]; then - # Exists and not a symlink, backup it - if [[ ! -e "$backup_path" ]]; then - echo " Backing up: $dst -> $backup_path" - mv "$dst" "$backup_path" - else - echo " Backup exists, removing: $dst" - rm -rf "$dst" - fi + echo " Warning: $dst exists and is not a symlink, skipping" >&2 + return 1 fi - ln -s "$src" "$dst" - echo " Linked: $dst -> $src" + echo " Linked: $(basename "$dst")" } -# 1. Link bin scripts to agent-space profile -AGENT_BIN="${CLAUDE_DIR}/.agent-space/profile/bin" -mkdir -p "$AGENT_BIN" -echo "Linking bin/* to $AGENT_BIN/" -for script in "$SCRIPT_DIR/bin"/*; do - [[ -f "$script" ]] || continue - name=$(basename "$script") - link_item "$script" "$AGENT_BIN/$name" -done +# 1. Symlink jjtask CLI to ~/.local/bin +echo "CLI setup:" +mkdir -p "$BIN_DIR" +symlink "$SCRIPT_DIR/bin/jjtask" "$BIN_DIR/jjtask" || true -# 2. Link config to jj-config -JJ_CONFIG_DIR="${CLAUDE_DIR}/.agent-space/jj-config" -mkdir -p "$JJ_CONFIG_DIR" -echo "" -echo "Linking config/conf.d/ to $JJ_CONFIG_DIR/" -for cfg in "$SCRIPT_DIR/config/conf.d"/*.toml; do - [[ -f "$cfg" ]] || continue - name=$(basename "$cfg") - link_item "$cfg" "$JJ_CONFIG_DIR/$name" -done +# 2. Symlink jjtask-go in plugin dir to local build +if [[ -x "$SCRIPT_DIR/bin/jjtask-go" ]]; then + dst="$PLUGIN_SOURCE/bin/jjtask-go" + if [[ -e "$dst" ]] || [[ -L "$dst" ]]; then + rm "$dst" + fi + ln -s "$SCRIPT_DIR/bin/jjtask-go" "$dst" + echo " Linked: plugin jjtask-go -> bin/jjtask-go" +else + echo " Skipping plugin binary (run 'mise run build' first)" +fi -# 3. Link commands -COMMANDS_DIR="${CLAUDE_DIR}/commands/jjtask" -mkdir -p "$COMMANDS_DIR" +# 3. Fish shell setup echo "" -echo "Linking claude-plugin/commands/* to $COMMANDS_DIR/" -for cmd in "$SCRIPT_DIR/claude-plugin/commands"/*.md; do - [[ -f "$cmd" ]] || continue - name=$(basename "$cmd") - link_item "$cmd" "$COMMANDS_DIR/$name" -done +echo "Fish setup:" +if [[ -f "$SCRIPT_DIR/shell/fish/functions/jjtask-env.fish" ]]; then + mkdir -p "$FISH_FUNCTIONS_DIR" + symlink "$SCRIPT_DIR/shell/fish/functions/jjtask-env.fish" "$FISH_FUNCTIONS_DIR/jjtask-env.fish" || true +fi -# 4. Link skills -SKILLS_DIR="${CLAUDE_DIR}/skills" -mkdir -p "$SKILLS_DIR" -echo "" -echo "Linking claude-plugin/skills/ to $SKILLS_DIR/" -for skill_dir in "$SCRIPT_DIR/claude-plugin/skills"/*; do - [[ -d "$skill_dir" ]] || continue - name=$(basename "$skill_dir") - link_item "$skill_dir" "$SKILLS_DIR/$name" -done +if [[ -x "$SCRIPT_DIR/bin/jjtask-go" ]]; then + comp_dir="${__fish_config_dir:-${XDG_CONFIG_HOME:-$HOME/.config}/fish}/completions" + mkdir -p "$comp_dir" + "$SCRIPT_DIR/bin/jjtask-go" completion fish > "$comp_dir/jjtask.fish" + "$SCRIPT_DIR/bin/jjtask-go" jj-completion fish > "$comp_dir/jj_task.fish" + echo " Generated: jjtask.fish, jj_task.fish" +else + echo " Skipping completions (run 'mise run build' first)" +fi +# 4. JJ alias echo "" -echo "Development setup complete!" +echo "JJ setup:" +current_alias=$(jj config get aliases.task 2>/dev/null || echo "") +if [[ -z "$current_alias" ]]; then + jj config set --user 'aliases.task' '["util", "exec", "--", "jjtask"]' + echo " Set: jj task -> jjtask" +else + echo " Already set: jj alias.task" +fi + +# 5. Point Claude Code plugin to local source echo "" -echo "Changes to files in $SCRIPT_DIR will now be immediately" -echo "available in Claude Code sessions." +echo "Claude Code plugin setup:" +if [[ -f "$PLUGINS_JSON" ]] && grep -q '"jjtask@jjtask-marketplace"' "$PLUGINS_JSON"; then + CURRENT_PATH=$(grep -A5 '"jjtask@jjtask-marketplace"' "$PLUGINS_JSON" | grep installPath | head -1 | sed 's/.*: "//;s/".*//') + + if [[ "$CURRENT_PATH" == "$PLUGIN_SOURCE" ]]; then + echo " Already pointing to dev source" + else + mkdir -p "$(dirname "$BACKUP_FILE")" + if [[ ! -f "$BACKUP_FILE" ]]; then + cp "$PLUGINS_JSON" "$BACKUP_FILE" + echo " Backed up: installed_plugins.json" + fi + sed -i '' "s|$CURRENT_PATH|$PLUGIN_SOURCE|" "$PLUGINS_JSON" + echo " Updated: installPath -> $PLUGIN_SOURCE" + fi +else + echo " Skipping (plugin not installed via marketplace)" +fi + +# 6. Agent-space JJ config +AGENT_JJ_CONFIG="${HOME}/.config/claude/.agent-space/jj-config" +if [[ -d "${HOME}/.config/claude/.agent-space" ]]; then + mkdir -p "$AGENT_JJ_CONFIG" + for cfg in "$SCRIPT_DIR/config/conf.d"/*.toml; do + [[ -f "$cfg" ]] || continue + name=$(basename "$cfg") + symlink "$cfg" "$AGENT_JJ_CONFIG/$name" || true + done +fi + echo "" -echo "To restore original setup: ./dev-teardown.sh" +echo "Done. Ensure ~/.local/bin is in PATH." +echo "Run ./dev-teardown.sh to restore release plugin version." diff --git a/dev-teardown.sh b/dev-teardown.sh index 62d2b44..b00cc6c 100755 --- a/dev-teardown.sh +++ b/dev-teardown.sh @@ -1,77 +1,50 @@ #!/usr/bin/env bash -# Development teardown: restore original ~/.config/claude/ setup +# Development teardown: restore original plugin setup # Usage: ./dev-teardown.sh # -# Removes symlinks created by dev-setup.sh and restores backups. +# Restores installed_plugins.json from backup. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -CLAUDE_DIR="${HOME}/.config/claude" -BACKUP_DIR="${SCRIPT_DIR}/.dev-backup" +PLUGINS_JSON="${HOME}/.claude/plugins/installed_plugins.json" +BACKUP_FILE="${SCRIPT_DIR}/.dev-backup/installed_plugins.json" echo "Tearing down jjtask development environment..." echo "" -# Helper to remove symlink and restore backup -unlink_item() { - local dst="$1" - local backup_path="$BACKUP_DIR/$(basename "$dst")" - - if [[ -L "$dst" ]]; then - rm "$dst" - echo " Removed symlink: $dst" - - if [[ -e "$backup_path" ]]; then - mv "$backup_path" "$dst" - echo " Restored backup: $backup_path -> $dst" - fi - fi -} - -# 1. Unlink bin scripts -AGENT_BIN="${CLAUDE_DIR}/.agent-space/profile/bin" -if [[ -d "$AGENT_BIN" ]]; then - echo "Unlinking bin scripts from $AGENT_BIN/" - for script in "$SCRIPT_DIR/bin"/*; do - [[ -f "$script" ]] || continue - name=$(basename "$script") - unlink_item "$AGENT_BIN/$name" - done +if [[ ! -f "$BACKUP_FILE" ]]; then + echo "No backup found at $BACKUP_FILE" + echo "Nothing to restore." + exit 0 fi -# 2. Unlink config -JJ_CONFIG_DIR="${CLAUDE_DIR}/.agent-space/jj-config" -echo "" -echo "Unlinking config from $JJ_CONFIG_DIR/" -unlink_item "$JJ_CONFIG_DIR/10-jjtask.toml" +echo "Restoring installed_plugins.json from backup..." +cp "$BACKUP_FILE" "$PLUGINS_JSON" +rm "$BACKUP_FILE" -# 3. Unlink commands -COMMANDS_DIR="${CLAUDE_DIR}/commands/jjtask" -if [[ -d "$COMMANDS_DIR" ]]; then +# Clean up agent-space symlinks +AGENT_JJ_CONFIG="${HOME}/.config/claude/.agent-space/jj-config" +if [[ -d "$AGENT_JJ_CONFIG" ]]; then echo "" - echo "Unlinking commands from $COMMANDS_DIR/" - for cmd in "$SCRIPT_DIR/commands"/*.md; do - [[ -f "$cmd" ]] || continue - name=$(basename "$cmd") - unlink_item "$COMMANDS_DIR/$name" + echo "Removing JJ config symlinks from agent-space..." + for cfg in "$SCRIPT_DIR/config/conf.d"/*.toml; do + [[ -f "$cfg" ]] || continue + name=$(basename "$cfg") + cfg_link="$AGENT_JJ_CONFIG/$name" + if [[ -L "$cfg_link" ]]; then + rm "$cfg_link" + echo " Removed: $name" + fi done - # Remove directory if empty - rmdir "$COMMANDS_DIR" 2>/dev/null || true fi -# 4. Unlink skills -SKILLS_DIR="${CLAUDE_DIR}/skills" -echo "" -echo "Unlinking skills from $SKILLS_DIR/" -unlink_item "$SKILLS_DIR/jjtask" -unlink_item "$SKILLS_DIR/jj-dev" - # Clean up backup directory if empty +BACKUP_DIR="${SCRIPT_DIR}/.dev-backup" if [[ -d "$BACKUP_DIR" ]] && [[ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]]; then rmdir "$BACKUP_DIR" fi echo "" echo "Development teardown complete!" -echo "Original ~/.config/claude/ setup restored." +echo "Plugin now points to cached marketplace version." diff --git a/dev.sh b/dev.sh deleted file mode 100755 index eea65a7..0000000 --- a/dev.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# Development setup - symlinks jjtask and jjtask-env.fish for direct usage -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -BIN_DIR="${HOME}/.local/bin" -FISH_FUNCTIONS_DIR="${__fish_config_dir:-${XDG_CONFIG_HOME:-$HOME/.config}/fish}/functions" - -symlink() { - local src="$1" dst="$2" - if [[ -L "$dst" ]]; then - local current=$(readlink "$dst") - if [[ "$current" == "$src" ]]; then - return 0 - fi - rm "$dst" - elif [[ -e "$dst" ]]; then - echo " Warning: $dst exists and is not a symlink, skipping" >&2 - return 1 - fi - ln -s "$src" "$dst" - echo " $(basename "$dst")" -} - -ensure_bin_scripts() { - mkdir -p "$BIN_DIR" - echo "Symlinking jjtask to $BIN_DIR:" - symlink "$SCRIPT_DIR/bin/jjtask" "$BIN_DIR/jjtask" || true -} - -ensure_fish_function() { - local src="$SCRIPT_DIR/shell/fish/functions/jjtask-env.fish" - local dst="$FISH_FUNCTIONS_DIR/jjtask-env.fish" - if [[ ! -f "$src" ]]; then - echo "Error: $src not found" >&2 - return 1 - fi - mkdir -p "$FISH_FUNCTIONS_DIR" - echo "Symlinking jjtask-env.fish:" - symlink "$src" "$dst" -} - -ensure_fish_completions() { - local binary="$SCRIPT_DIR/bin/jjtask-go" - if [[ ! -x "$binary" ]]; then - echo "Skipping fish completions (run 'mise run build' first)" - return - fi - local comp_dir="${__fish_config_dir:-${XDG_CONFIG_HOME:-$HOME/.config}/fish}/completions" - mkdir -p "$comp_dir" - echo "Generating fish completions:" - "$binary" completion fish > "$comp_dir/jjtask.fish" - echo " jjtask.fish" - "$binary" jj-completion fish > "$comp_dir/jj_task.fish" - echo " jj_task.fish (jj task completions)" -} - -ensure_jj_alias() { - local current - current=$(jj config get aliases.task 2>/dev/null || echo "") - if [[ -z "$current" ]]; then - echo "Setting jj alias.task:" - jj config set --user 'aliases.task' '["util", "exec", "--", "jjtask"]' - echo " jj task -> jjtask" - else - echo "jj alias.task already set" - fi -} - -ensure_bin_scripts -ensure_fish_function -ensure_fish_completions -ensure_jj_alias - -echo "" -echo "Done. Ensure ~/.local/bin is in PATH." diff --git a/docs/mega-merge-workflow.md b/docs/mega-merge-workflow.md new file mode 100644 index 0000000..6f553cd --- /dev/null +++ b/docs/mega-merge-workflow.md @@ -0,0 +1,124 @@ +# Mega-Merge Workflow + +Based on [simultaneous edits](https://steveklabnik.github.io/jujutsu-tutorial/advanced/simultaneous-edits.html) and [megamerges](https://v5.chriskrycho.com/journal/jujutsu-megamerges-and-jj-absorb/). + +## The Problem + +With jjtask, tasks were floating on side branches: +- Create task (side branch) +- Work in @ (main line) +- Squash into task (still side branch) +- Forget to merge back → orphan branches + +Result: constantly fighting with "where is my work?", manual hoist/squash operations, tasks getting lost. + +## The Solution + +**@ is always a merge of all WIP tasks.** + +``` +@ = merge(all_wip_tasks) +``` + +Done tasks linearize into ancestry - they become ancestors of remaining WIP tasks, creating clean linear history ready for push. + +## Commands + +| Command | What it does | +|---------|--------------| +| `jjtask wip [tasks...]` | Mark WIP + add as parents of @ | +| `jjtask done [tasks...]` | Mark done + linearize into ancestry | +| `jjtask drop [tasks...]` | Remove from @ merge (mark as standby) | +| `jjtask squash` | Flatten @ merge into linear commit for push | + +## Workflow + +```bash +# 1. Create tasks (planning) +jjtask create trunk "Feature A" +jjtask create trunk "Feature B" + +# 2. Start work - @ becomes merge of WIP tasks +jjtask wip feature-a +jjtask wip feature-b + +# 3. Work in task branches directly, or use merge for visibility +jj edit feature-a # Work directly in the task +# or stay in merge and use: jj absorb --into 'tasks_wip()' + +# 4. Complete a task - drops out of merge +jjtask done feature-a + +# 5. Ready to push? Flatten the merge +jjtask squash +``` + +## How It Works + +**wip**: Adds task as parent of @ using `jj rebase -r @ -o existing_parents -o new_task`. Preserves @ content. + +**done**: Marks task done, then linearizes - rebases other parents onto the done task, so it becomes an ancestor instead of a floating branch. + +**drop**: Removes task from @ parents using rebase. Marks as standby. + +All operations preserve @ content through rebase rather than creating new commits. + +## Routing Changes to Tasks + +When working in a merge, you need to get changes into the right task branch. + +**Option 1: Work directly in task branch (safest)** +```bash +jj edit task-a # Work directly in the task +# make changes... +jj new task-a task-b # Recreate merge to see combined state +``` + +**Option 2: Use absorb with explicit targets** +```bash +jj absorb --into 'tasks_wip()' # Only absorb into WIP tasks, not ancestors +``` + +**Avoid bare `jj absorb`** - it routes changes based on where lines were last modified, which could send edits to ancestor commits deep in history if you're modifying lines that weren't touched by your task branches. + +## Benefits + +- **No orphan branches** - work is always in @ +- **See conflicts immediately** - merge commit shows them +- **Clean separation** - done work drops out, WIP stays +- **Linear history when ready** - `squash` flattens for push + +## Edge Cases + +- **Conflicts between WIP tasks**: @ shows conflict markers immediately +- **Want to isolate one task**: `jj edit task` still works +- **Stale branches**: Done tasks not in @'s ancestry should be abandoned + +## Parallel Work (Multiple Agents) + +For multiple Claude sessions working simultaneously, use jj workspaces: + +```bash +# Terminal 1: Create workspace for task A +jj workspace add .workspaces/agent-a --revision task-a +cd .workspaces/agent-a +# work... +jjtask done + +# Terminal 2: Create workspace for task B +jj workspace add .workspaces/agent-b --revision task-b +cd .workspaces/agent-b +# work... +jjtask done + +# Main workspace sees merged state automatically +``` + +Each workspace has its own @ that mega-merge rebuilds independently. + +## Implementation + +Core helpers in `internal/jj/jj.go`: +- `GetParents(rev)` - returns parent change IDs +- `AddToMerge(task)` - adds task as parent of @ via rebase +- `RemoveFromMerge(task)` - removes task from @ parents via rebase diff --git a/go.mod b/go.mod index 2d567ec..c46bf84 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.3 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index 230fd8c..9a5f972 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c949836 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,191 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" + "gopkg.in/yaml.v3" +) + +// Config represents .jjtask.toml +type Config struct { + Workspaces WorkspacesConfig `toml:"workspaces"` + Prime PrimeConfig `toml:"prime"` +} + +// WorkspacesConfig holds multi-repo workspace configuration +type WorkspacesConfig struct { + Repos []Repo `toml:"repos"` +} + +// Repo represents a single repo in the config +type Repo struct { + Path string `toml:"path" yaml:"path"` + Name string `toml:"name" yaml:"name"` +} + +// PrimeConfig holds prime output customization +type PrimeConfig struct { + Content string `toml:"content"` + ContentFile string `toml:"content_file"` +} + +var configRoot string +var loadedConfig *Config + +// FindConfig locates .jjtask.toml or .jj-workspaces.yaml by traversing up from cwd +// Returns path to config file and root directory +func FindConfig() (cfgPath, root string, err error) { + dir, err := os.Getwd() + if err != nil { + return "", "", err + } + for dir != "/" { + // Prefer .jjtask.toml + tomlPath := filepath.Join(dir, ".jjtask.toml") + if _, err := os.Stat(tomlPath); err == nil { + return tomlPath, dir, nil + } + // Fall back to legacy .jj-workspaces.yaml + yamlPath := filepath.Join(dir, ".jj-workspaces.yaml") + if _, err := os.Stat(yamlPath); err == nil { + return yamlPath, dir, nil + } + dir = filepath.Dir(dir) + } + return "", "", nil +} + +// Load reads the config file, supporting both TOML and YAML formats +func Load() (*Config, string, error) { + if loadedConfig != nil { + return loadedConfig, configRoot, nil + } + + cfgPath, root, err := FindConfig() + if err != nil { + return nil, "", err + } + if cfgPath == "" { + return nil, "", nil + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + return nil, "", err + } + + cfg := &Config{} + + if filepath.Ext(cfgPath) == ".yaml" { + // Legacy YAML format - auto-migrate to TOML + var yamlCfg struct { + Repos []Repo `yaml:"repos"` + } + if err := yaml.Unmarshal(data, &yamlCfg); err != nil { + return nil, "", err + } + cfg.Workspaces.Repos = yamlCfg.Repos + + // Migrate to .jjtask.toml + if err := migrateYAMLToTOML(cfgPath, root, cfg); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to migrate config: %v\n", err) + } + } else { + if err := toml.Unmarshal(data, cfg); err != nil { + return nil, "", err + } + } + + configRoot = root + loadedConfig = cfg + return cfg, root, nil +} + +// GetRepos returns list of repo paths +func GetRepos() ([]Repo, string, error) { + cfg, root, err := Load() + if err != nil { + return nil, "", err + } + if cfg == nil || len(cfg.Workspaces.Repos) == 0 { + return []Repo{{Path: ".", Name: "workspace"}}, "", nil + } + return cfg.Workspaces.Repos, root, nil +} + +// IsMultiRepo returns true if multi-repo config exists +func IsMultiRepo() bool { + cfg, _, _ := Load() + return cfg != nil && len(cfg.Workspaces.Repos) > 1 +} + +// GetPrimeContent returns custom prime content if configured +// Returns content string and bool indicating if custom content exists +func GetPrimeContent() (content string, hasCustom bool, err error) { + cfg, root, err := Load() + if err != nil { + return "", false, err + } + if cfg == nil { + return "", false, nil + } + + // Inline content takes precedence + if cfg.Prime.Content != "" { + return cfg.Prime.Content, true, nil + } + + // Content file path (relative to config root) + if cfg.Prime.ContentFile != "" { + filePath := cfg.Prime.ContentFile + if !filepath.IsAbs(filePath) { + filePath = filepath.Join(root, filePath) + } + data, err := os.ReadFile(filePath) + if err != nil { + return "", false, err + } + return string(data), true, nil + } + + return "", false, nil +} + +// Reset clears cached config (for testing) +func Reset() { + loadedConfig = nil + configRoot = "" +} + +// migrateYAMLToTOML converts .jj-workspaces.yaml to .jjtask.toml +func migrateYAMLToTOML(yamlPath, root string, cfg *Config) error { + tomlPath := filepath.Join(root, ".jjtask.toml") + + // Don't overwrite existing TOML + if _, err := os.Stat(tomlPath); err == nil { + return nil + } + + // Generate clean inline array syntax + var content string + content = "[workspaces]\nrepos = [\n" + for _, repo := range cfg.Workspaces.Repos { + content += fmt.Sprintf(" { path = %q, name = %q },\n", repo.Path, repo.Name) + } + content += "]\n" + + if err := os.WriteFile(tomlPath, []byte(content), 0o644); err != nil { + return err + } + + // Remove old YAML file + if err := os.Remove(yamlPath); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not remove %s: %v\n", yamlPath, err) + } + + fmt.Fprintf(os.Stderr, "migrated %s → %s\n", filepath.Base(yamlPath), filepath.Base(tomlPath)) + return nil +} diff --git a/internal/jj/jj.go b/internal/jj/jj.go index 5c5e277..bfbed65 100644 --- a/internal/jj/jj.go +++ b/internal/jj/jj.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "golang.org/x/term" @@ -239,3 +240,129 @@ func SetupEnv() { } } } + +// GetActiveRevisions returns change IDs of WIP tasks only +func (c *Client) GetActiveRevisions() ([]string, error) { + out, err := c.Query("log", "-r", "tasks_wip()", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + if err != nil { + return nil, err + } + out = strings.TrimSpace(out) + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +// isRootChangeID checks if a change ID is the root commit (all z's) +func isRootChangeID(id string) bool { + for _, c := range id { + if c != 'z' { + return false + } + } + return true +} + +// GetParents returns the parent change IDs of a revision +func (c *Client) GetParents(rev string) ([]string, error) { + out, err := c.Query("log", "-r", "parents("+rev+")", "--no-graph", "-T", `change_id.shortest() ++ "\n"`) + if err != nil { + return nil, err + } + out = strings.TrimSpace(out) + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +// RemoveFromMerge removes a revision from @'s parents, preserving @ content +func (c *Client) RemoveFromMerge(task string) error { + parents, err := c.GetParents("@") + if err != nil { + return fmt.Errorf("getting parents: %w", err) + } + + // Filter out the task and root + var remaining []string + for _, p := range parents { + if p != task && !isRootChangeID(p) { + remaining = append(remaining, p) + } + } + + if len(remaining) == 0 { + return nil + } + + if len(remaining) == 1 { + if err := c.Run("squash", "--into", remaining[0], "--keep-emptied"); err != nil { + return fmt.Errorf("squashing into parent: %w", err) + } + return c.Run("edit", remaining[0]) + } + + // Multiple parents - rebase @ onto remaining parents + args := []string{"rebase", "-r", "@"} + for _, p := range remaining { + args = append(args, "-o", p) + } + return c.Run(args...) +} + +// IsAncestorOf checks if rev is an ancestor of target +func (c *Client) IsAncestorOf(rev, target string) (bool, error) { + out, err := c.Query("log", "-r", rev+"::"+target, "--no-graph", "-T", "change_id.shortest()", "--limit", "1") + if err != nil { + // Empty result means not an ancestor + return false, nil + } + return strings.TrimSpace(out) != "", nil +} + +// AddToMerge adds a revision as a new parent of @, preserving @ content +func (c *Client) AddToMerge(task string) error { + // Get @ change ID to check if task IS @ + atID, err := c.Query("log", "-r", "@", "--no-graph", "-T", "change_id.shortest()") + if err != nil { + return fmt.Errorf("getting @ ID: %w", err) + } + atID = strings.TrimSpace(atID) + + // If task is @ itself, nothing to do + if task == atID { + return nil + } + + parents, err := c.GetParents("@") + if err != nil { + return fmt.Errorf("getting parents: %w", err) + } + + // Check if already a parent + if slices.Contains(parents, task) { + return nil + } + + // Filter out root commit - can't merge with root + var validParents []string + for _, p := range parents { + if !isRootChangeID(p) { + validParents = append(validParents, p) + } + } + + if len(validParents) == 0 { + // No real parents - just rebase onto task + return c.Run("rebase", "-r", "@", "-o", task) + } + + // Rebase @ onto existing parents + new task + args := []string{"rebase", "-r", "@"} + for _, p := range validParents { + args = append(args, "-o", p) + } + args = append(args, "-o", task) + return c.Run(args...) +} diff --git a/internal/parallel/conflicts.go b/internal/parallel/conflicts.go deleted file mode 100644 index 2589f94..0000000 --- a/internal/parallel/conflicts.go +++ /dev/null @@ -1,180 +0,0 @@ -package parallel - -import ( - "fmt" - "path/filepath" - "strings" - - "jjtask/internal/jj" -) - -// Conflict represents a file modified by multiple agents -type Conflict struct { - File string - Agents []string -} - -// GetModifiedFiles returns files modified in a revision -func GetModifiedFiles(client *jj.Client, rev string) ([]string, error) { - out, err := client.Query("diff", "-r", rev, "--name-only") - if err != nil { - return nil, err - } - - var files []string - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - if line != "" { - files = append(files, line) - } - } - return files, nil -} - -// FindFileConflicts detects files modified by multiple agents -func FindFileConflicts(client *jj.Client, session *Session) ([]Conflict, error) { - if session.Mode != "workspace" { - return nil, nil // Can only check workspace mode - } - - // Map of file -> agents that modified it - fileAgents := make(map[string][]string) - - var errs []error - for _, agent := range session.Agents { - if agent.TaskID == "" { - continue - } - - files, err := GetModifiedFiles(client, agent.TaskID) - if err != nil { - errs = append(errs, fmt.Errorf("get files for %s: %w", agent.ID, err)) - continue - } - - for _, file := range files { - fileAgents[file] = append(fileAgents[file], agent.ID) - } - } - if len(errs) > 0 && len(fileAgents) == 0 { - return nil, fmt.Errorf("failed to get modified files: %v", errs) - } - - var conflicts []Conflict - for file, agents := range fileAgents { - if len(agents) > 1 { - conflicts = append(conflicts, Conflict{ - File: file, - Agents: agents, - }) - } - } - - return conflicts, nil -} - -// PatternsOverlap checks if two glob patterns could match the same files -func PatternsOverlap(patternA, patternB string) bool { - if patternA == "" || patternB == "" { - return false - } - - // Simple heuristics for common cases - // Full overlap detection would require glob expansion - - // Direct prefix match - if strings.HasPrefix(patternA, patternB) || strings.HasPrefix(patternB, patternA) { - return true - } - - // Strip trailing ** and check prefix - baseA := strings.TrimSuffix(patternA, "**") - baseA = strings.TrimSuffix(baseA, "*") - baseA = strings.TrimSuffix(baseA, "/") - - baseB := strings.TrimSuffix(patternB, "**") - baseB = strings.TrimSuffix(baseB, "*") - baseB = strings.TrimSuffix(baseB, "/") - - if baseA != "" && baseB != "" { - if strings.HasPrefix(baseA, baseB) || strings.HasPrefix(baseB, baseA) { - return true - } - } - - return false -} - -// CheckPatternOverlaps returns warnings for overlapping agent assignments -func CheckPatternOverlaps(session *Session) []string { - var warnings []string - - for i, agentA := range session.Agents { - for j, agentB := range session.Agents { - if i >= j { - continue - } - if PatternsOverlap(agentA.FilePattern, agentB.FilePattern) { - warnings = append(warnings, fmt.Sprintf( - "%s (%s) and %s (%s) may overlap", - agentA.ID, agentA.FilePattern, - agentB.ID, agentB.FilePattern, - )) - } - } - } - - return warnings -} - -// FileMatchesPattern checks if a file path matches a glob pattern -func FileMatchesPattern(file, pattern string) bool { - if pattern == "" { - return false - } - - // Handle ** patterns - if strings.Contains(pattern, "**") { - // Convert ** to match any path - parts := strings.Split(pattern, "**") - if len(parts) == 2 { - prefix := parts[0] - suffix := strings.TrimPrefix(parts[1], "/") - - if !strings.HasPrefix(file, prefix) { - return false - } - - if suffix == "" { - return true - } - - // Check suffix match - matched, _ := filepath.Match(suffix, filepath.Base(file)) - return matched - } - } - - // Simple glob match - matched, _ := filepath.Match(pattern, file) - if matched { - return true - } - - // Try matching just the filename - matched, _ = filepath.Match(pattern, filepath.Base(file)) - return matched -} - -// FormatConflicts returns a formatted string of conflicts for display -func FormatConflicts(conflicts []Conflict) string { - if len(conflicts) == 0 { - return "" - } - - var b strings.Builder - b.WriteString("Conflicts detected:\n") - for _, c := range conflicts { - b.WriteString(fmt.Sprintf(" %s modified by: %s\n", c.File, strings.Join(c.Agents, ", "))) - } - return b.String() -} diff --git a/internal/parallel/schema.go b/internal/parallel/schema.go deleted file mode 100644 index a93f460..0000000 --- a/internal/parallel/schema.go +++ /dev/null @@ -1,197 +0,0 @@ -package parallel - -import ( - "fmt" - "regexp" - "strings" - "time" -) - -// Session holds parallel agent session info parsed from task description -type Session struct { - Mode string // "shared" or "workspace" - Started time.Time // session start time - Agents []Agent -} - -// Agent represents a single agent's assignment -type Agent struct { - ID string // e.g. "agent-a" - FilePattern string // glob pattern like "src/api/**" - Description string // what this agent is doing - TaskID string // change ID for workspace mode, empty for shared -} - -var agentLineRe = regexp.MustCompile(`^- ([\w-]+):\s*([^|]+?)\s*\|\s*([^|]+?)\s*(?:\|\s*task:(\w+))?$`) - -// ParseSession extracts parallel session info from task description -func ParseSession(description string) (*Session, error) { - lines := strings.Split(description, "\n") - - var inParallelSection, inAgentsSection bool - session := &Session{} - var parseErrors []string - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - // Detect section headers - if strings.HasPrefix(trimmed, "## Parallel Session") { - inParallelSection = true - inAgentsSection = false - continue - } - if strings.HasPrefix(trimmed, "### Agents") { - inAgentsSection = true - continue - } - if strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "### ") { - if inParallelSection && strings.HasPrefix(trimmed, "## ") { - inParallelSection = false - } - if inAgentsSection && (strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "### ")) { - inAgentsSection = false - } - continue - } - - if !inParallelSection { - continue - } - - // Parse key: value lines in Parallel Session - if !inAgentsSection { - if strings.HasPrefix(trimmed, "mode:") { - session.Mode = strings.TrimSpace(strings.TrimPrefix(trimmed, "mode:")) - } else if strings.HasPrefix(trimmed, "started:") { - ts := strings.TrimSpace(strings.TrimPrefix(trimmed, "started:")) - t, err := time.Parse(time.RFC3339, ts) - if err == nil { - session.Started = t - } - } - } - - // Parse agent lines - if inAgentsSection && strings.HasPrefix(trimmed, "- ") { - match := agentLineRe.FindStringSubmatch(trimmed) - if match != nil { - agent := Agent{ - ID: match[1], - FilePattern: strings.TrimSpace(match[2]), - Description: strings.TrimSpace(match[3]), - } - if len(match) > 4 && match[4] != "" { - agent.TaskID = match[4] - } - session.Agents = append(session.Agents, agent) - } else { - parseErrors = append(parseErrors, fmt.Sprintf("invalid agent line: %s", trimmed)) - } - } - } - - if session.Mode == "" && len(session.Agents) == 0 { - return nil, nil // no parallel session found - } - - if len(parseErrors) > 0 { - return session, fmt.Errorf("parse warnings: %s", strings.Join(parseErrors, "; ")) - } - - return session, nil -} - -// FormatSession generates markdown for a parallel session -func FormatSession(session *Session) string { - var b strings.Builder - - b.WriteString("## Parallel Session\n") - b.WriteString(fmt.Sprintf("mode: %s\n", session.Mode)) - if !session.Started.IsZero() { - b.WriteString(fmt.Sprintf("started: %s\n", session.Started.Format(time.RFC3339))) - } - b.WriteString("\n### Agents\n") - - for _, agent := range session.Agents { - if agent.TaskID != "" { - b.WriteString(fmt.Sprintf("- %s: %s | %s | task:%s\n", agent.ID, agent.FilePattern, agent.Description, agent.TaskID)) - } else { - b.WriteString(fmt.Sprintf("- %s: %s | %s\n", agent.ID, agent.FilePattern, agent.Description)) - } - } - - return b.String() -} - -// UpdateDescription inserts or replaces parallel session in description -func UpdateDescription(description string, session *Session) string { - sessionText := FormatSession(session) - - // Find existing parallel session section - lines := strings.Split(description, "\n") - var result []string - var inParallelSection bool - var inserted bool - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "## Parallel Session") { - inParallelSection = true - continue - } - - if inParallelSection { - // Look for next ## section to end parallel section - if strings.HasPrefix(trimmed, "## ") { - inParallelSection = false - if !inserted { - result = append(result, sessionText) - inserted = true - } - result = append(result, line) - } - continue - } - - // Insert before ## Requirements or ## Acceptance if we haven't yet - if !inserted && (strings.HasPrefix(trimmed, "## Requirements") || strings.HasPrefix(trimmed, "## Acceptance")) { - result = append(result, sessionText) - result = append(result, "") - inserted = true - } - - result = append(result, line) - - // If at end and haven't inserted, do it now - if i == len(lines)-1 && !inserted { - result = append(result, "") - result = append(result, sessionText) - inserted = true - } - } - - return strings.Join(result, "\n") -} - -// GetAgentByID finds an agent by ID -func (s *Session) GetAgentByID(id string) *Agent { - for i := range s.Agents { - if s.Agents[i].ID == id { - return &s.Agents[i] - } - } - return nil -} - -// OtherAgents returns all agents except the given one -func (s *Session) OtherAgents(excludeID string) []Agent { - var others []Agent - for _, a := range s.Agents { - if a.ID != excludeID { - others = append(others, a) - } - } - return others -} diff --git a/internal/parallel/workspace.go b/internal/parallel/workspace.go deleted file mode 100644 index 4240674..0000000 --- a/internal/parallel/workspace.go +++ /dev/null @@ -1,176 +0,0 @@ -package parallel - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "jjtask/internal/jj" -) - -const WorkspacesDir = ".jjtask-workspaces" - -// EnsureWorkspacesDir creates .jjtask-workspaces/ if it doesn't exist -func EnsureWorkspacesDir(repoRoot string) (string, error) { - dir := filepath.Join(repoRoot, WorkspacesDir) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("create workspaces dir: %w", err) - } - return dir, nil -} - -// EnsureIgnored adds .jjtask-workspaces/ to .git/info/exclude -func EnsureIgnored(repoRoot string) error { - gitDir := filepath.Join(repoRoot, ".git") - if _, err := os.Stat(gitDir); os.IsNotExist(err) { - return nil // non-colocated repo, nothing to do - } - - infoDir := filepath.Join(gitDir, "info") - if err := os.MkdirAll(infoDir, 0o755); err != nil { - return fmt.Errorf("create .git/info: %w", err) - } - - excludePath := filepath.Join(infoDir, "exclude") - pattern := WorkspacesDir + "/" - - // Read existing content - content, err := os.ReadFile(excludePath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("read exclude file: %w", err) - } - - // Check if already present - if strings.Contains(string(content), pattern) { - return nil - } - - // Append pattern - f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return fmt.Errorf("open exclude file: %w", err) - } - defer func() { _ = f.Close() }() - - // Add newline if file doesn't end with one - if len(content) > 0 && content[len(content)-1] != '\n' { - if _, err := f.WriteString("\n"); err != nil { - return err - } - } - if _, err := f.WriteString(pattern + "\n"); err != nil { - return err - } - - return nil -} - -// CreateWorkspace creates a jj workspace for an agent -func CreateWorkspace(client *jj.Client, repoRoot, agentID, revision string) (string, error) { - wsDir, err := EnsureWorkspacesDir(repoRoot) - if err != nil { - return "", err - } - - agentDir := filepath.Join(wsDir, agentID) - - // Check if workspace already exists - if _, err := os.Stat(agentDir); err == nil { - return agentDir, nil // already exists - } - - // Create jj workspace - err = client.Run("workspace", "add", agentDir, "--revision", revision, "--name", agentID) - if err != nil { - return "", fmt.Errorf("create workspace: %w", err) - } - - return agentDir, nil -} - -// CleanupWorkspace removes a single agent workspace -func CleanupWorkspace(client *jj.Client, repoRoot, agentID string) error { - agentDir := filepath.Join(repoRoot, WorkspacesDir, agentID) - - // Check for uncommitted changes - hasChanges, err := workspaceHasChanges(client, agentDir) - if err != nil { - return fmt.Errorf("check workspace status: %w", err) - } - if hasChanges { - return fmt.Errorf("workspace %s has uncommitted changes", agentID) - } - - // Forget workspace in jj - _ = client.Run("workspace", "forget", agentID) - - // Remove directory - if err := os.RemoveAll(agentDir); err != nil { - return fmt.Errorf("remove workspace dir: %w", err) - } - - return nil -} - -// CleanupAllWorkspaces removes all agent workspaces -func CleanupAllWorkspaces(client *jj.Client, repoRoot string) error { - wsDir := filepath.Join(repoRoot, WorkspacesDir) - entries, err := os.ReadDir(wsDir) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("read workspaces dir: %w", err) - } - - var errors []string - for _, entry := range entries { - if !entry.IsDir() { - continue - } - if err := CleanupWorkspace(client, repoRoot, entry.Name()); err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", entry.Name(), err)) - } - } - - if len(errors) > 0 { - return fmt.Errorf("cleanup errors: %s", strings.Join(errors, "; ")) - } - - // Remove workspaces dir if empty - remaining, _ := os.ReadDir(wsDir) - if len(remaining) == 0 { - _ = os.Remove(wsDir) - } - - return nil -} - -// ListWorkspaces returns active agent workspaces -func ListWorkspaces(repoRoot string) ([]string, error) { - wsDir := filepath.Join(repoRoot, WorkspacesDir) - entries, err := os.ReadDir(wsDir) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - - var workspaces []string - for _, entry := range entries { - if entry.IsDir() { - workspaces = append(workspaces, entry.Name()) - } - } - return workspaces, nil -} - -func workspaceHasChanges(client *jj.Client, wsDir string) (bool, error) { - out, err := client.Query("-R", wsDir, "diff", "--stat") - if err != nil { - return false, err - } - return strings.TrimSpace(out) != "", nil -} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 62e6f94..b4b5729 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -6,76 +6,34 @@ import ( "path/filepath" "strings" - "gopkg.in/yaml.v3" + "jjtask/internal/config" ) -// Config represents .jj-workspaces.yaml -type Config struct { - Repos []Repo `yaml:"repos"` -} +// Repo is an alias for config.Repo for backwards compatibility +type Repo = config.Repo -// Repo represents a single repo in the config -type Repo struct { - Path string `yaml:"path"` - Name string `yaml:"name"` -} +// Config is an alias for backwards compatibility +type Config = config.Config -// FindConfig locates .jj-workspaces.yaml by traversing up from cwd +// FindConfig delegates to config package func FindConfig() (string, error) { - dir, err := os.Getwd() - if err != nil { - return "", err - } - for dir != "/" { - configPath := filepath.Join(dir, ".jj-workspaces.yaml") - if _, err := os.Stat(configPath); err == nil { - return configPath, nil - } - dir = filepath.Dir(dir) - } - return "", nil + path, _, err := config.FindConfig() + return path, err } -// Load reads the workspace config -func Load() (*Config, string, error) { - configPath, err := FindConfig() - if err != nil { - return nil, "", err - } - if configPath == "" { - return nil, "", nil - } - - data, err := os.ReadFile(configPath) - if err != nil { - return nil, "", err - } - - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, "", err - } - - root := filepath.Dir(configPath) - return &cfg, root, nil +// Load delegates to config package +func Load() (cfg *Config, root string, err error) { + return config.Load() } -// IsMultiRepo returns true if multi-repo config exists +// IsMultiRepo delegates to config package func IsMultiRepo() bool { - cfg, _, _ := Load() - return cfg != nil && len(cfg.Repos) > 1 + return config.IsMultiRepo() } -// GetRepos returns list of repo paths -func GetRepos() ([]Repo, string, error) { - cfg, root, err := Load() - if err != nil { - return nil, "", err - } - if cfg == nil { - return []Repo{{Path: ".", Name: "workspace"}}, "", nil - } - return cfg.Repos, root, nil +// GetRepos delegates to config package +func GetRepos() (repos []Repo, root string, err error) { + return config.GetRepos() } // ResolveRepoPath resolves a repo path relative to workspace root @@ -121,7 +79,7 @@ func RelativePath(target string) string { // ContextHint returns context hint for multi-repo or subdirectory usage func ContextHint() string { - cfg, workspaceRoot, err := Load() + cfg, workspaceRoot, err := config.Load() if err != nil || cfg == nil { return "" } @@ -141,7 +99,8 @@ func ContextHint() string { realRoot = workspaceRoot } - isMulti := len(cfg.Repos) > 1 + repos := cfg.Workspaces.Repos + isMulti := len(repos) > 1 inSubdir := realCwd != realRoot if !isMulti && !inSubdir { @@ -150,7 +109,7 @@ func ContextHint() string { // Find which repo we're in var currentRepo string - for _, repo := range cfg.Repos { + for _, repo := range repos { repoPath := ResolveRepoPath(repo, workspaceRoot) realRepo, err := filepath.EvalSymlinks(repoPath) if err != nil { diff --git a/test.sh b/test.sh deleted file mode 100755 index d60a102..0000000 --- a/test.sh +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env bash -# Run jjtask tests in parallel (bounded by CPU cores) -# Usage: ./test.sh [-j N] [--sequential] - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "$SCRIPT_DIR/test/test_helper.bash" - -# Colors (only if terminal) -if [[ -t 1 ]]; then - RED='\033[0;31m' - GREEN='\033[0;32m' - NC='\033[0m' -else - RED='' - GREEN='' - NC='' -fi - -# Run a single test in isolation, output result to stdout -run_one_test() { - local name="$1" - local func="$2" - local setup_func="${3:-setup_test_repo}" - local teardown_func="${4:-teardown_test_repo}" - - $setup_func - - if $func; then - echo -e "${GREEN}✓${NC} $name" - $teardown_func - return 0 - else - echo -e "${RED}✗${NC} $name" - $teardown_func - return 1 - fi -} - -# Test definitions - each is: "name|function|setup|teardown" -TESTS=( - # Basic tests - "jjtask create creates a task|test_jot_create" - "jjtask create with --draft flag|test_jot_create_draft" - "jjtask flag updates task status|test_jot_flag" - "jjtask find shows tasks|test_jot_find" - "jjtask find output format (snapshot)|test_jot_find_snapshot" - "jjtask find -r filters to tasks only|test_jot_find_revset_filters_tasks" - "jjtask show-desc outputs description|test_jot_show_desc" - "jjtask parallel creates sibling tasks|test_jot_parallel" - "jjtask finalize strips task prefix|test_jot_finalize" - "jjtask prime outputs context|test_jot_prime" - "jjtask checkpoint creates checkpoint|test_jot_checkpoint" - "jjtask desc-transform transforms description|test_jot_desc_transform" - "jjtask desc-transform error on invalid command|test_jot_desc_transform_error" - "jj config: task_log shows diff stats for @|test_config_task_log_diff_stats" - "jj config: task_log shows short descriptions|test_config_task_log_short_desc" - # Hoist tests - "jjtask hoist single task|test_hoist_single" - "jjtask hoist multiple tasks|test_hoist_multiple" - "jjtask hoist no-op when already hoisted|test_hoist_noop" - "jjtask hoist nested tasks|test_hoist_nested" - "jjtask hoist with no pending tasks|test_hoist_empty" - # Multi-repo tests - "jjtask find across repos|test_multi_jot_find|setup_multi_repo|teardown_multi_repo" - "jjtask all log across repos|test_multi_jot_all_log|setup_multi_repo|teardown_multi_repo" - "workspace hint from subdirectory|test_multi_workspace_hint|setup_multi_repo|teardown_multi_repo" - "complex multi-repo with tasks|test_multi_complex_tasks|setup_multi_repo|teardown_multi_repo" - # Parallel agent tests - "parallel-start shared mode|test_parallel_start_shared" - "parallel-start workspace mode|test_parallel_start_workspace" - "parallel-status shows session|test_parallel_status" - "agent-context returns info|test_agent_context" - "parallel-stop cleans up|test_parallel_stop" - "parallel-start invalid mode error|test_parallel_start_invalid_mode" - "agent-context unknown agent error|test_agent_context_unknown" -) - -# Test functions -test_jot_create() { - jjtask create @ "Test task" "Test description" >/dev/null 2>&1 - has_task_with_flag todo -} - -test_jot_create_draft() { - jjtask create --draft @ "Draft task" >/dev/null 2>&1 - has_task_with_flag draft -} - -test_jot_flag() { - jjtask create @ "Test task" >/dev/null 2>&1 - local task_id - task_id=$(get_task_id todo) - [[ -n "$task_id" ]] || return 1 - jjtask flag "$task_id" wip >/dev/null 2>&1 - has_task_with_flag wip -} - -test_jot_find() { - jjtask create @ "Task A" >/dev/null 2>&1 - jjtask create @ "Task B" >/dev/null 2>&1 - local output - output=$(jjtask find 2>/dev/null) - [[ "$output" == *"Task A"* ]] && [[ "$output" == *"Task B"* ]] -} - -test_jot_find_snapshot() { - jjtask create @ "First task" "Description A" >/dev/null 2>&1 - jjtask create @ "Second task" "Description B" >/dev/null 2>&1 - local output - output=$(jjtask find 2>/dev/null) - assert_snapshot "find_tasks" "$output" -} - -test_jot_find_revset_filters_tasks() { - # Create a task and a regular commit - find -r should only show tasks - jjtask create @ "My task" >/dev/null 2>&1 - jj new -m "Regular commit" >/dev/null 2>&1 - local output - # Using all() would show both, but find -r should filter to tasks only - output=$(jjtask find -r 'all()' 2>/dev/null) - [[ "$output" == *"My task"* ]] || return 1 - # Should NOT contain the regular commit - [[ "$output" != *"Regular commit"* ]] -} - -test_jot_show_desc() { - jjtask create @ "Test title" "Test body content" >/dev/null 2>&1 - local task_id - task_id=$(get_task_id todo) - [[ -n "$task_id" ]] || return 1 - jj edit "$task_id" >/dev/null 2>&1 - local output - output=$(jjtask show-desc @) - assert_snapshot "show_desc" "$output" -} - -test_jot_parallel() { - jjtask parallel @ "Task A" "Task B" "Task C" >/dev/null 2>&1 - local output - output=$(jjtask find 2>/dev/null) - assert_snapshot "parallel_tasks" "$output" -} - -test_jot_finalize() { - jjtask create @ "Finalize test" "## Done criteria -- Task completed" >/dev/null 2>&1 - local task_id - task_id=$(get_task_id todo) - [[ -n "$task_id" ]] || return 1 - jjtask flag "$task_id" done >/dev/null 2>&1 - jjtask finalize "$task_id" >/dev/null 2>&1 - local output - output=$(jjtask show-desc "$task_id") - assert_snapshot "finalize_output" "$output" -} - -test_jot_prime() { - local output - output=$(jjtask prime) - assert_snapshot "prime_output" "$output" -} - -test_jot_checkpoint() { - local output - output=$(jjtask checkpoint "test-checkpoint") - assert_snapshot "checkpoint_output" "$output" -} - -test_jot_desc_transform() { - jjtask create @ "Original title" "## Context -Some context here" >/dev/null 2>&1 - local task_id - task_id=$(get_task_id todo) - [[ -n "$task_id" ]] || return 1 - jjtask desc-transform "$task_id" sed 's/Original/Modified/' >/dev/null 2>&1 - local output - output=$(jjtask show-desc "$task_id") - assert_snapshot "desc_transform_output" "$output" -} - -test_jot_desc_transform_error() { - jjtask create @ "Test title" >/dev/null 2>&1 - local task_id - task_id=$(get_task_id todo) - [[ -n "$task_id" ]] || return 1 - local output - output=$(jjtask desc-transform "$task_id" "nonexistent-cmd-xyz" 2>&1) && return 1 - assert_snapshot "desc_transform_error" "$output" -} - -test_config_task_log_diff_stats() { - echo "test content" > testfile.txt - jj describe -m "Test commit with changes" >/dev/null 2>&1 - local output - output=$(jj log -r @ --no-graph -T task_log 2>/dev/null) - assert_snapshot "task_log_diff_stats" "$output" -} - -test_config_task_log_short_desc() { - jjtask create @ "Short title" "## Context -This is a longer description -with multiple lines" >/dev/null 2>&1 - local task_id - task_id=$(get_task_id todo) - [[ -n "$task_id" ]] || return 1 - local output - output=$(jj log -r "$task_id" --no-graph -T task_log 2>/dev/null) - assert_snapshot "task_log_short_desc" "$output" -} - -test_hoist_single() { - jjtask create @ "Task to hoist" >/dev/null 2>&1 - jj new -m "New work" >/dev/null 2>&1 - local output - output=$(jjtask hoist 2>&1) - assert_snapshot "hoist_single" "$output" -} - -test_hoist_multiple() { - jjtask create @ "Task A" >/dev/null 2>&1 - jjtask create @ "Task B" >/dev/null 2>&1 - jjtask create @ "Task C" >/dev/null 2>&1 - jj new -m "New work" >/dev/null 2>&1 - local output - output=$(jjtask hoist 2>&1) - assert_snapshot "hoist_multiple" "$output" -} - -test_hoist_noop() { - jjtask create @ "Already hoisted task" >/dev/null 2>&1 - local output - output=$(jjtask hoist 2>&1) - assert_snapshot "hoist_noop" "$output" -} - -test_hoist_nested() { - jjtask create @ "Parent task" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - jjtask create "$parent_id" "Child task" >/dev/null 2>&1 - jj new -m "New work" >/dev/null 2>&1 - local output - output=$(jjtask hoist 2>&1) - local find_output - find_output=$(jjtask find 2>/dev/null) - assert_snapshot "hoist_nested" "$output"$'\n'"---"$'\n'"$find_output" -} - -test_hoist_empty() { - local output - output=$(jjtask hoist 2>&1) - assert_snapshot "hoist_empty" "$output" -} - -test_multi_jot_find() { - (cd frontend && jjtask create @ "Frontend task" >/dev/null 2>&1) - (cd backend && jjtask create @ "Backend task" >/dev/null 2>&1) - local output - output=$(jjtask find 2>/dev/null) - assert_snapshot "multi_repo_find" "$output" -} - -test_multi_jot_all_log() { - (cd frontend && echo "test" > file.txt && jj describe -m "Frontend commit" >/dev/null 2>&1) - (cd backend && echo "test" > file.txt && jj describe -m "Backend commit" >/dev/null 2>&1) - local output - output=$(jjtask all log -r @ 2>/dev/null) - assert_snapshot "multi_repo_all_log" "$output" -} - -test_multi_workspace_hint() { - (cd frontend && jjtask create @ "Frontend task" >/dev/null 2>&1) - mkdir -p frontend/src - local output - output=$(cd frontend/src && jjtask find 2>/dev/null) - assert_snapshot "multi_workspace_hint" "$output" -} - -test_multi_complex_tasks() { - jjtask create @ "ROOT: CI/CD pipeline" >/dev/null 2>&1 - jjtask create --draft @ "ROOT: Terraform modules" >/dev/null 2>&1 - jjtask create @ "ROOT: Integration tests" >/dev/null 2>&1 - - (cd frontend && \ - jjtask create @ "FE: Auth login page" >/dev/null 2>&1 && \ - jjtask create --draft @ "FE: Dark mode toggle" >/dev/null 2>&1 && \ - jjtask create @ "FE: Error boundaries" >/dev/null 2>&1) - - (cd backend && \ - jjtask create @ "BE: User API endpoints" >/dev/null 2>&1 && \ - jjtask create --draft @ "BE: GraphQL schema" >/dev/null 2>&1 && \ - jjtask create @ "BE: Background jobs" >/dev/null 2>&1) - - local output - output=$(jjtask find 2>/dev/null) - assert_snapshot "multi_repo_complex" "$output" -} - -test_parallel_start_shared() { - jjtask create @ "Parent task" "## Context -Test parallel session" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - [[ -n "$parent_id" ]] || return 1 - jj edit "$parent_id" >/dev/null 2>&1 - - local output - output=$(jjtask parallel-start --mode shared --agents 2 "$parent_id" 2>&1) - assert_snapshot "parallel_start_shared" "$output" -} - -test_parallel_start_workspace() { - jjtask create @ "Parent task" "## Context -Test workspace mode" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - [[ -n "$parent_id" ]] || return 1 - jj edit "$parent_id" >/dev/null 2>&1 - - local output - output=$(jjtask parallel-start --mode workspace --agents 2 "$parent_id" 2>&1) - [[ -d ".jjtask-workspaces/agent-a" ]] || return 1 - [[ -d ".jjtask-workspaces/agent-b" ]] || return 1 - assert_snapshot "parallel_start_workspace" "$output" -} - -test_parallel_status() { - jjtask create @ "Parent task" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - [[ -n "$parent_id" ]] || return 1 - jj edit "$parent_id" >/dev/null 2>&1 - - jjtask parallel-start --mode shared --agents 2 "$parent_id" >/dev/null 2>&1 - local output - output=$(jjtask parallel-status "$parent_id" 2>&1) - assert_snapshot "parallel_status" "$output" -} - -test_agent_context() { - jjtask create @ "Parent task" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - [[ -n "$parent_id" ]] || return 1 - jj edit "$parent_id" >/dev/null 2>&1 - - jjtask parallel-start --mode shared --agents 2 "$parent_id" >/dev/null 2>&1 - local output - output=$(jjtask agent-context agent-a 2>&1) - assert_snapshot "agent_context" "$output" -} - -test_parallel_stop() { - jjtask create @ "Parent task" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - [[ -n "$parent_id" ]] || return 1 - jj edit "$parent_id" >/dev/null 2>&1 - - jjtask parallel-start --mode shared --agents 2 "$parent_id" >/dev/null 2>&1 - local output - output=$(jjtask parallel-stop --force "$parent_id" 2>&1) - assert_snapshot "parallel_stop" "$output" -} - -test_parallel_start_invalid_mode() { - jjtask create @ "Parent task" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - [[ -n "$parent_id" ]] || return 1 - jj edit "$parent_id" >/dev/null 2>&1 - - local output - output=$(jjtask parallel-start --mode invalid "$parent_id" 2>&1) && return 1 - [[ "$output" == *"invalid mode"* ]] -} - -test_agent_context_unknown() { - jjtask create @ "Parent task" >/dev/null 2>&1 - local parent_id - parent_id=$(get_task_id todo) - [[ -n "$parent_id" ]] || return 1 - jj edit "$parent_id" >/dev/null 2>&1 - - jjtask parallel-start --mode shared --agents 2 "$parent_id" >/dev/null 2>&1 - local output - output=$(jjtask agent-context agent-xyz 2>&1) && return 1 - [[ "$output" == *"not in session"* ]] -} - -# Main execution -echo "Running jjtask tests..." -echo "" - -# Parse args -SEQUENTIAL=false -JOBS="" -while [[ $# -gt 0 ]]; do - case "$1" in - --sequential) SEQUENTIAL=true; shift ;; - -j) JOBS="$2"; shift 2 ;; - -j*) JOBS="${1#-j}"; shift ;; - *) shift ;; - esac -done - -# Default to CPU core count -if [[ -z "$JOBS" ]]; then - JOBS=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) -fi - -FAILED=0 -PASSED=0 -TOTAL=${#TESTS[@]} - -if [[ "$SEQUENTIAL" == "true" ]]; then - # Sequential mode - for test_spec in "${TESTS[@]}"; do - IFS='|' read -r name func setup teardown <<< "$test_spec" - setup="${setup:-setup_test_repo}" - teardown="${teardown:-teardown_test_repo}" - if run_one_test "$name" "$func" "$setup" "$teardown"; then - PASSED=$((PASSED + 1)) - else - FAILED=$((FAILED + 1)) - fi - done -else - # Parallel mode - run tests with job limit - RESULT_DIR=$(mktemp -d) - RUNNING=0 - NEXT=0 - - while [[ $NEXT -lt $TOTAL ]] || [[ $RUNNING -gt 0 ]]; do - # Launch jobs up to limit - while [[ $RUNNING -lt $JOBS ]] && [[ $NEXT -lt $TOTAL ]]; do - i=$NEXT - test_spec="${TESTS[$i]}" - IFS='|' read -r name func setup teardown <<< "$test_spec" - setup="${setup:-setup_test_repo}" - teardown="${teardown:-teardown_test_repo}" - - ( - if run_one_test "$name" "$func" "$setup" "$teardown" > "$RESULT_DIR/$i.out" 2>&1; then - echo "0" > "$RESULT_DIR/$i.exit" - else - echo "1" > "$RESULT_DIR/$i.exit" - fi - ) & - echo "$!" > "$RESULT_DIR/$i.pid" - RUNNING=$((RUNNING + 1)) - NEXT=$((NEXT + 1)) - done - - # Wait for any job to finish - if [[ $RUNNING -gt 0 ]]; then - wait -n 2>/dev/null || sleep 0.1 - # Count still-running jobs - RUNNING=0 - for ((j=0; j<NEXT; j++)); do - if [[ -f "$RESULT_DIR/$j.pid" ]] && ! [[ -f "$RESULT_DIR/$j.exit" ]]; then - pid=$(cat "$RESULT_DIR/$j.pid") - if kill -0 "$pid" 2>/dev/null; then - RUNNING=$((RUNNING + 1)) - fi - fi - done - fi - done - - # Print results in order - for i in "${!TESTS[@]}"; do - cat "$RESULT_DIR/$i.out" - if [[ "$(cat "$RESULT_DIR/$i.exit")" == "0" ]]; then - PASSED=$((PASSED + 1)) - else - FAILED=$((FAILED + 1)) - fi - done - - rm -rf "$RESULT_DIR" -fi - -echo "" -echo "Results: $PASSED/$TOTAL passed" - -if [[ $FAILED -gt 0 ]]; then - echo -e "${RED}$FAILED test(s) failed${NC}" - exit 1 -else - echo -e "${GREEN}All tests passed!${NC}" - exit 0 -fi diff --git a/test/snapshots/agent_context.txt b/test/snapshots/agent_context.txt deleted file mode 100644 index e5566dd..0000000 --- a/test/snapshots/agent_context.txt +++ /dev/null @@ -1,11 +0,0 @@ -## Agent Context: agent-a - -Mode: shared - -### Parent Task -ID [task:todo] Parent task - -### DAG State -@ ← ID [task:todo] Parent task -│ -~ diff --git a/test/snapshots/checkpoint_output.txt b/test/snapshots/checkpoint_output.txt deleted file mode 100644 index 6d3cee2..0000000 --- a/test/snapshots/checkpoint_output.txt +++ /dev/null @@ -1,7 +0,0 @@ -Checkpoint 'test-checkpoint' at operation: OPID - Restore with: jj op restore OPID - - Current state: -@ ID (ID) (no description set) -│ -~ diff --git a/test/snapshots/desc_transform_error.txt b/test/snapshots/desc_transform_error.txt deleted file mode 100644 index a349e01..0000000 --- a/test/snapshots/desc_transform_error.txt +++ /dev/null @@ -1 +0,0 @@ -Error: command "nonexistent-cmd-xyz" not found diff --git a/test/snapshots/desc_transform_output.txt b/test/snapshots/desc_transform_output.txt deleted file mode 100644 index 59b053a..0000000 --- a/test/snapshots/desc_transform_output.txt +++ /dev/null @@ -1,4 +0,0 @@ -[task:todo] Modified title - -## Context -Some context here diff --git a/test/snapshots/finalize_output.txt b/test/snapshots/finalize_output.txt deleted file mode 100644 index df22daa..0000000 --- a/test/snapshots/finalize_output.txt +++ /dev/null @@ -1,4 +0,0 @@ -Finalize test - -## Done criteria -- Task completed diff --git a/test/snapshots/find_tasks.txt b/test/snapshots/find_tasks.txt deleted file mode 100644 index 3490c78..0000000 --- a/test/snapshots/find_tasks.txt +++ /dev/null @@ -1,7 +0,0 @@ -○ ID [task:todo] Second task -│ Description B -│ ○ ID [task:todo] First task -├─╯ Description A -@ ID -│ -~ diff --git a/test/snapshots/hoist_empty.txt b/test/snapshots/hoist_empty.txt deleted file mode 100644 index 0d8e888..0000000 --- a/test/snapshots/hoist_empty.txt +++ /dev/null @@ -1 +0,0 @@ -No stale tasks to hoist diff --git a/test/snapshots/hoist_multiple.txt b/test/snapshots/hoist_multiple.txt deleted file mode 100644 index 73497fb..0000000 --- a/test/snapshots/hoist_multiple.txt +++ /dev/null @@ -1,3 +0,0 @@ -Found 3 task root(s) to hoist: ID -Rebased 3 commits to destination -Done diff --git a/test/snapshots/hoist_nested.txt b/test/snapshots/hoist_nested.txt deleted file mode 100644 index a1cc8d1..0000000 --- a/test/snapshots/hoist_nested.txt +++ /dev/null @@ -1,9 +0,0 @@ -Found 1 task root(s) to hoist: ID -Rebased 2 commits to destination -Done ---- -○ ID [task:todo] Child task -○ ID [task:todo] Parent task -@ ID New work -│ -~ diff --git a/test/snapshots/hoist_noop.txt b/test/snapshots/hoist_noop.txt deleted file mode 100644 index 5198da0..0000000 --- a/test/snapshots/hoist_noop.txt +++ /dev/null @@ -1,4 +0,0 @@ -Found 1 task root(s) to hoist: ID -Skipped rebase of 1 commits that were already in place -Nothing changed. -Done diff --git a/test/snapshots/hoist_single.txt b/test/snapshots/hoist_single.txt deleted file mode 100644 index 3a157a4..0000000 --- a/test/snapshots/hoist_single.txt +++ /dev/null @@ -1,3 +0,0 @@ -Found 1 task root(s) to hoist: ID -Rebased 1 commits to destination -Done diff --git a/test/snapshots/multi_repo_all_log.txt b/test/snapshots/multi_repo_all_log.txt deleted file mode 100644 index 6387816..0000000 --- a/test/snapshots/multi_repo_all_log.txt +++ /dev/null @@ -1,17 +0,0 @@ -cwd: TMPDIR | repo: root - -=== root: jj -R . log === -@ ID (ID) (no description set) -│ -~ - -=== frontend: jj -R ./frontend log === -@ ID Frontend commit -│ frontend/file.txt | 1 + -~ 1 file changed, 1 insertion(+), 0 deletions(-) - - -=== backend: jj -R ./backend log === -@ ID Backend commit -│ backend/file.txt | 1 + -~ 1 file changed, 1 insertion(+), 0 deletions(-) diff --git a/test/snapshots/multi_repo_complex.txt b/test/snapshots/multi_repo_complex.txt deleted file mode 100644 index 522216f..0000000 --- a/test/snapshots/multi_repo_complex.txt +++ /dev/null @@ -1,31 +0,0 @@ -cwd: TMPDIR | repo: root - -=== root: jj -R . log === -○ ID [task:todo] ROOT: Integration tests -│ ○ ID [task:draft] ROOT: Terraform modules -├─╯ -│ ○ ID [task:todo] ROOT: CI/CD pipeline -├─╯ -@ ID -│ -~ - -=== frontend: jj -R ./frontend log === -○ ID [task:todo] FE: Error boundaries -│ ○ ID [task:draft] FE: Dark mode toggle -├─╯ -│ ○ ID [task:todo] FE: Auth login page -├─╯ -@ ID -│ -~ - -=== backend: jj -R ./backend log === -○ ID [task:todo] BE: Background jobs -│ ○ ID [task:draft] BE: GraphQL schema -├─╯ -│ ○ ID [task:todo] BE: User API endpoints -├─╯ -@ ID -│ -~ diff --git a/test/snapshots/multi_repo_find.txt b/test/snapshots/multi_repo_find.txt deleted file mode 100644 index 18ad08b..0000000 --- a/test/snapshots/multi_repo_find.txt +++ /dev/null @@ -1,18 +0,0 @@ -cwd: TMPDIR | repo: root - -=== root: jj -R . log === -@ ID -│ -~ - -=== frontend: jj -R ./frontend log === -○ ID [task:todo] Frontend task -@ ID -│ -~ - -=== backend: jj -R ./backend log === -○ ID [task:todo] Backend task -@ ID -│ -~ diff --git a/test/snapshots/multi_workspace_hint.txt b/test/snapshots/multi_workspace_hint.txt deleted file mode 100644 index bac4ea0..0000000 --- a/test/snapshots/multi_workspace_hint.txt +++ /dev/null @@ -1,17 +0,0 @@ -cwd: TMPDIR | repo: TMPDIR | workspace: ../.. - -=== root: jj -R ../.. log === -@ ID -│ -~ - -=== frontend: jj -R .. log === -○ ID [task:todo] Frontend task -@ ID -│ -~ - -=== backend: jj -R ../../backend log === -@ ID -│ -~ diff --git a/test/snapshots/parallel_start_shared.txt b/test/snapshots/parallel_start_shared.txt deleted file mode 100644 index cdc6e85..0000000 --- a/test/snapshots/parallel_start_shared.txt +++ /dev/null @@ -1,16 +0,0 @@ -Warning: No file assignments specified for shared mode -Use --assign to specify: --assign "agent-a:src/api/**,agent-b:src/ui/**" -Without assignments, agents may conflict by editing the same files - -Working copy at: ID HASH (empty) [task:wip] Parent task -Parent commit: ID HASH (empty) (no description set) -Working copy at: ID HASH (empty) [task:todo] Parent task -Parent commit: ID HASH (empty) (no description set) -Parallel session started (mode: shared) - -Agent: agent-a - -Agent: agent-b - -To get agent context: jjtask agent-context <agent-id> -Note: Agents share @ - coordinate file assignments before starting diff --git a/test/snapshots/parallel_start_workspace.txt b/test/snapshots/parallel_start_workspace.txt deleted file mode 100644 index 707fb25..0000000 --- a/test/snapshots/parallel_start_workspace.txt +++ /dev/null @@ -1,25 +0,0 @@ -Created new commit ID HASH (empty) [task:wip] agent-a task -Created workspace in ".jjtask-workspaces/agent-a" -Working copy at: ID HASH (empty) (no description set) -Parent commit: ID HASH (empty) [task:wip] agent-a task -Created new commit ID HASH (empty) [task:wip] agent-b task -Created workspace in ".jjtask-workspaces/agent-b" -Working copy at: ID HASH (empty) (no description set) -Parent commit: ID HASH (empty) [task:wip] agent-b task -Rebased N descendant commits -Working copy at: ID HASH (empty) [task:wip] Parent task -Parent commit: ID HASH (empty) (no description set) -Rebased N descendant commits -Working copy at: ID HASH (empty) [task:todo] Parent task -Parent commit: ID HASH (empty) (no description set) -Parallel session started (mode: workspace) - -Agent: agent-a - Workspace: .jjtask-workspaces/agent-a - Task: ID - -Agent: agent-b - Workspace: .jjtask-workspaces/agent-b - Task: ID - -To get agent context: jjtask agent-context <agent-id> diff --git a/test/snapshots/parallel_status.txt b/test/snapshots/parallel_status.txt deleted file mode 100644 index f501d8b..0000000 --- a/test/snapshots/parallel_status.txt +++ /dev/null @@ -1,16 +0,0 @@ -Parallel Session: ID [task:todo] Parent task - -Mode: shared -Started: just now ago - -Agent Status Task Changes ------ ------ ---- ------- -agent-a todo (shared) no changes -agent-b todo (shared) no changes - -DAG: -@ ID (ID) [task:todo] Parent task +10L -│ mode: shared started: TIMESTAMP -~ ### Agents - - agent-a: | Agent 1 - - agent-b: | Agent 2 diff --git a/test/snapshots/parallel_stop.txt b/test/snapshots/parallel_stop.txt deleted file mode 100644 index 7466f31..0000000 --- a/test/snapshots/parallel_stop.txt +++ /dev/null @@ -1,3 +0,0 @@ -Working copy at: ID HASH (empty) [task:todo] Parent task -Parent commit: ID HASH (empty) (no description set) -Parallel session stopped diff --git a/test/snapshots/parallel_tasks.txt b/test/snapshots/parallel_tasks.txt deleted file mode 100644 index d6bf907..0000000 --- a/test/snapshots/parallel_tasks.txt +++ /dev/null @@ -1,8 +0,0 @@ -○ ID [task:todo] Task C -│ ○ ID [task:todo] Task B -├─╯ -│ ○ ID [task:todo] Task A -├─╯ -@ ID -│ -~ diff --git a/test/snapshots/prime_output.txt b/test/snapshots/prime_output.txt deleted file mode 100644 index a583a7a..0000000 --- a/test/snapshots/prime_output.txt +++ /dev/null @@ -1,45 +0,0 @@ - -## JJ TASK Quick Reference - -Task flags: draft → todo → wip → done (also: blocked, standby, untested, review) - -### Revsets -tasks(), tasks_pending(), tasks_todo(), tasks_wip(), tasks_done(), tasks_blocked() - -### Commands (all support -R, --quiet, etc.) -jjtask find [FLAG] [-r REVSET] List tasks (flags: todo/wip/done/all, -r for revset) -jjtask create PARENT TITLE [DESC] Create task child of PARENT (required) -jjtask flag REV FLAG Change task flag -jjtask next [--mark-as FLAG] [REV] Review current specs, optionally transition -jjtask hoist Rebase pending tasks to children of @ -jjtask finalize [REV] Strip [task:*] prefix for final commit -jjtask parallel PARENT T1 T2... Create sibling tasks under PARENT -jjtask show-desc [REV] Print revision description -jjtask desc-transform REV SED_EXPR Transform description with sed -jjtask batch-desc SED_EXPR REVSET Transform multiple descriptions -jjtask checkpoint [MSG] Create checkpoint commit -jjtask all CMD [ARGS] Run jj CMD across workspaces - -### Workflow -1. `/jjtask` - load skill for full workflow docs -2. `jjtask find` - see task DAG (DAG order = priority) -3. `jjtask show-desc REV` - read FULL spec before starting -4. `jj edit REV && jjtask flag @ wip` - start work -5. `jjtask hoist` - after commits, rebase tasks to stay children of @ -6. `jjtask next --mark-as done NEXT` - only when ALL criteria met - -### Rules -- DAG = priority: parent tasks complete before children -- Chain related tasks: `jjtask create PREV_TASK 'Next step'` -- Read full spec before editing - descriptions are specifications -- Never mark done unless ALL acceptance criteria pass -- Use --mark-as review/blocked/untested if incomplete -- `jjtask hoist` keeps task DAG connected to current work -- Stop and report if unsure - don't attempt JJ recovery ops - -### Native Task Tools -TaskCreate, TaskUpdate, TaskList, TaskGet - for session workflow tracking -Use for: multi-step work within a session, dependency ordering, progress display -jjtask = persistent tasks in repo history; Task* = ephemeral session tracking - -### Current Tasks diff --git a/test/snapshots/show_desc.txt b/test/snapshots/show_desc.txt deleted file mode 100644 index a48b87c..0000000 --- a/test/snapshots/show_desc.txt +++ /dev/null @@ -1,3 +0,0 @@ -[task:todo] Test title - -Test body content diff --git a/test/snapshots/task_log_diff_stats.txt b/test/snapshots/task_log_diff_stats.txt deleted file mode 100644 index 46a0a4d..0000000 --- a/test/snapshots/task_log_diff_stats.txt +++ /dev/null @@ -1,3 +0,0 @@ -ID Test commit with changes -testfile.txt | 1 + -1 file changed, 1 insertion(+), 0 deletions(-) diff --git a/test/snapshots/task_log_short_desc.txt b/test/snapshots/task_log_short_desc.txt deleted file mode 100644 index 9d750d3..0000000 --- a/test/snapshots/task_log_short_desc.txt +++ /dev/null @@ -1,2 +0,0 @@ -ID [task:todo] Short title +5L - This is a longer description with multiple lines diff --git a/test/snapshots_go/active_revisions_excludes_done_empty.txt b/test/snapshots_go/active_revisions_excludes_done_empty.txt new file mode 100644 index 0000000..c2d1619 --- /dev/null +++ b/test/snapshots_go/active_revisions_excludes_done_empty.txt @@ -0,0 +1,28 @@ +$ jjtask create Todo task +Created new commit rlvkpnrz f8c57ce0 (empty) [task:todo] Todo task +Created new commit r (empty) [task:todo] Todo task + +$ jjtask create --parent root() Wip task +Created new commit kkmpptxz 73c72577 (empty) [task:todo] Wip task +Created new commit k (empty) [task:todo] Wip task + +$ jjtask flag wip --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +$ jjtask flag done --rev r + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +$ jjtask create --parent root() Another task +Created new commit vruxwmqv 83be78aa (empty) [task:todo] Another task +Created new commit v (empty) [task:todo] Another task + +$ jjtask wip v +Rebuilding @ as merge of 2 active task(s) +Working copy (@) now at: znkkpsqq df07d306 (empty) (no description set) +Parent commit (@-) : vruxwmqv b4ae16eb (empty) [task:wip] Another task +Parent commit (@-) : kkmpptxz b51e6df5 (empty) [task:wip] Wip task + diff --git a/test/snapshots_go/active_revisions_only_wip.txt b/test/snapshots_go/active_revisions_only_wip.txt new file mode 100644 index 0000000..c2d1619 --- /dev/null +++ b/test/snapshots_go/active_revisions_only_wip.txt @@ -0,0 +1,28 @@ +$ jjtask create Todo task +Created new commit rlvkpnrz f8c57ce0 (empty) [task:todo] Todo task +Created new commit r (empty) [task:todo] Todo task + +$ jjtask create --parent root() Wip task +Created new commit kkmpptxz 73c72577 (empty) [task:todo] Wip task +Created new commit k (empty) [task:todo] Wip task + +$ jjtask flag wip --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +$ jjtask flag done --rev r + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +$ jjtask create --parent root() Another task +Created new commit vruxwmqv 83be78aa (empty) [task:todo] Another task +Created new commit v (empty) [task:todo] Another task + +$ jjtask wip v +Rebuilding @ as merge of 2 active task(s) +Working copy (@) now at: znkkpsqq df07d306 (empty) (no description set) +Parent commit (@-) : vruxwmqv b4ae16eb (empty) [task:wip] Another task +Parent commit (@-) : kkmpptxz b51e6df5 (empty) [task:wip] Wip task + diff --git a/test/snapshots_go/checkpoint.txt b/test/snapshots_go/checkpoint.txt new file mode 100644 index 0000000..8c27d3c --- /dev/null +++ b/test/snapshots_go/checkpoint.txt @@ -0,0 +1,17 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask checkpoint -m test-checkpoint +Checkpoint 'test-checkpoint' at operation: 8f47435a3990 + Restore with: jj op restore 8f47435a3990 + + Current state: +@ qpvuntsm test.user@example.com 2001-02-03 04:05:07 e8849ae1 +│ (empty) (no description set) +~ + +# after +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/config_task_log_diff_stats.txt b/test/snapshots_go/config_task_log_diff_stats.txt new file mode 100644 index 0000000..0aae4ed --- /dev/null +++ b/test/snapshots_go/config_task_log_diff_stats.txt @@ -0,0 +1,12 @@ +# wrote testfile.txt (12 bytes) + +$ jj describe -m Test commit with changes +Working copy (@) now at: qpvuntsm 986cd85f Test commit with changes +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +$ jj log -r @ --no-graph -T task_log +qpvuntsm Test commit with changes +testfile.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) + + diff --git a/test/snapshots_go/config_task_log_short_desc.txt b/test/snapshots_go/config_task_log_short_desc.txt new file mode 100644 index 0000000..5f3894f --- /dev/null +++ b/test/snapshots_go/config_task_log_short_desc.txt @@ -0,0 +1,19 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Short title ## Context +This is a longer description +with multiple lines +Created new commit kkmpptxz 38edb650 (empty) [task:todo] Short title +Created new commit k (empty) [task:todo] Short title + +# after +○ kkmpptxz [task:todo] Short title +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r k --no-graph -T task_log +kkmpptxz [task:todo] Short title [desc:5L] + This is a longer description with multiple lines + diff --git a/test/snapshots_go/create_chain_at.txt b/test/snapshots_go/create_chain_at.txt new file mode 100644 index 0000000..fbc77b1 --- /dev/null +++ b/test/snapshots_go/create_chain_at.txt @@ -0,0 +1,36 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --chain Task 1 +Created new commit kkmpptxz 705c213c (empty) [task:todo] Task 1 +Created new commit k (empty) [task:todo] Task 1 + +# after +○ kkmpptxz [task:todo] Task 1 +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --chain Task 2 +Created new commit royxmykx f6051aab (empty) [task:todo] Task 2 +Created new commit r (empty) [task:todo] Task 2 + +# after +○ royxmykx [task:todo] Task 2 +○ kkmpptxz [task:todo] Task 1 +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --chain Task 3 +Created new commit yostqsxw 34023bbd (empty) [task:todo] Task 3 +Created new commit y (empty) [task:todo] Task 3 + +# after +○ yostqsxw [task:todo] Task 3 +○ royxmykx [task:todo] Task 2 +○ kkmpptxz [task:todo] Task 1 +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r description(substring:"Task 3") & tasks() --no-graph -T parents.map(|p| p.description().first_line()).join(",") +[task:todo] Task 2 diff --git a/test/snapshots_go/create_chain_parent.txt b/test/snapshots_go/create_chain_parent.txt new file mode 100644 index 0000000..0f7eee7 --- /dev/null +++ b/test/snapshots_go/create_chain_parent.txt @@ -0,0 +1,36 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent +Created new commit kkmpptxz 6e748e86 (empty) [task:todo] Parent +Created new commit k (empty) [task:todo] Parent + +# after +○ kkmpptxz [task:todo] Parent +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --chain --parent k Child 1 +Created new commit yqosqzyt 9ea530b8 (empty) [task:todo] Child 1 +Created new commit y (empty) [task:todo] Child 1 + +# after +○ yqosqzyt [task:todo] Child 1 +○ kkmpptxz [task:todo] Parent +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --chain --parent k Child 2 +Created new commit znkkpsqq 2013a585 (empty) [task:todo] Child 2 +Created new commit zn (empty) [task:todo] Child 2 + +# after +○ znkkpsqq [task:todo] Child 2 +○ yqosqzyt [task:todo] Child 1 +○ kkmpptxz [task:todo] Parent +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r description(substring:"Child 2") & tasks() --no-graph -T parents.map(|p| p.description().first_line()).join(",") +[task:todo] Child 1 diff --git a/test/snapshots_go/create_default_direct.txt b/test/snapshots_go/create_default_direct.txt new file mode 100644 index 0000000..76737c0 --- /dev/null +++ b/test/snapshots_go/create_default_direct.txt @@ -0,0 +1,43 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent +Created new commit kkmpptxz 6e748e86 (empty) [task:todo] Parent +Created new commit k (empty) [task:todo] Parent + +# after +○ kkmpptxz [task:todo] Parent +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child 1 +Created new commit yqosqzyt 9ea530b8 (empty) [task:todo] Child 1 +Created new commit y (empty) [task:todo] Child 1 + +# after +○ yqosqzyt [task:todo] Child 1 +○ kkmpptxz [task:todo] Parent +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child 2 +Created new commit znkkpsqq c8645e2f (empty) [task:todo] Child 2 +Created new commit zn (empty) [task:todo] Child 2 + +# after +○ znkkpsqq [task:todo] Child 2 +│ ○ yqosqzyt [task:todo] Child 1 +├─╯ +○ kkmpptxz [task:todo] Parent +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r description(substring:"Child 2") & tasks() --no-graph -T parents.map(|p| p.description().first_line()).join(",") +[task:todo] Parent +$ jj log -r children(k) & tasks() --no-graph +znkkpsqq test.user@example.com 2001-02-03 04:05:16 c8645e2f +(empty) [task:todo] Child 2 +yqosqzyt test.user@example.com 2001-02-03 04:05:13 9ea530b8 +(empty) [task:todo] Child 1 + diff --git a/test/snapshots_go/create_draft.txt b/test/snapshots_go/create_draft.txt new file mode 100644 index 0000000..1fd35bf --- /dev/null +++ b/test/snapshots_go/create_draft.txt @@ -0,0 +1,23 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --draft @ Draft task +Created new commit kkmpptxz 3027c57a (empty) [task:draft] @ +Created new commit k (empty) [task:draft] @ + +# after +○ kkmpptxz [task:draft] @ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find --status all +○ kkmpptxz [task:draft] @ +│ Draft task +~ + +# after +○ kkmpptxz [task:draft] @ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/create_no_suggestion_not_wip.txt b/test/snapshots_go/create_no_suggestion_not_wip.txt new file mode 100644 index 0000000..e3754de --- /dev/null +++ b/test/snapshots_go/create_no_suggestion_not_wip.txt @@ -0,0 +1,13 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create New task +Created new commit kkmpptxz 410d4e2a (empty) [task:todo] New task +Created new commit k (empty) [task:todo] New task + +# after +○ kkmpptxz [task:todo] New task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/create_no_suggestion_parent_at.txt b/test/snapshots_go/create_no_suggestion_parent_at.txt new file mode 100644 index 0000000..b1d9efc --- /dev/null +++ b/test/snapshots_go/create_no_suggestion_parent_at.txt @@ -0,0 +1,40 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create WIP task +Created new commit kkmpptxz 58a1a4cf (empty) [task:todo] WIP task +Created new commit k (empty) [task:todo] WIP task + +# after +○ kkmpptxz [task:todo] WIP task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:wip] WIP task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz a42f3902 (empty) [task:wip] WIP task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ kkmpptxz [task:wip] WIP task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Child of wip +Created new commit kpqxywon 52f6d4ba (empty) [task:todo] Child of wip +Created new commit kp (empty) [task:todo] Child of wip + +# after +○ kpqxywon [task:todo] Child of wip +@ kkmpptxz [task:wip] WIP task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/create_suggests_chaining_when_wip.txt b/test/snapshots_go/create_suggests_chaining_when_wip.txt new file mode 100644 index 0000000..b6204da --- /dev/null +++ b/test/snapshots_go/create_suggests_chaining_when_wip.txt @@ -0,0 +1,45 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create WIP task +Created new commit kkmpptxz 58a1a4cf (empty) [task:todo] WIP task +Created new commit k (empty) [task:todo] WIP task + +# after +○ kkmpptxz [task:todo] WIP task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:wip] WIP task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz a42f3902 (empty) [task:wip] WIP task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ kkmpptxz [task:wip] WIP task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent root() Other task + +Note: Current revision (k) is a WIP task. +Consider: `jjtask create "title"` to auto-chain from @ + +Created new commit kpqxywon abc47162 (empty) [task:todo] Other task +Created new commit kp (empty) [task:todo] Other task + +# after +@ kkmpptxz [task:wip] WIP task +○ qpvuntsm +│ ○ kpqxywon [task:todo] Other task +├─╯ +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/create_task.txt b/test/snapshots_go/create_task.txt new file mode 100644 index 0000000..7b640e9 --- /dev/null +++ b/test/snapshots_go/create_task.txt @@ -0,0 +1,25 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Test task Test description +Created new commit kkmpptxz 26ec814e (empty) [task:todo] Test task +Created new commit k (empty) [task:todo] Test task + +# after +○ kkmpptxz [task:todo] Test task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find +○ kkmpptxz [task:todo] Test task +│ Test description +@ qpvuntsm +│ +~ + +# after +○ kkmpptxz [task:todo] Test task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/create_wip_suggestion_snapshot.txt b/test/snapshots_go/create_wip_suggestion_snapshot.txt new file mode 100644 index 0000000..b6204da --- /dev/null +++ b/test/snapshots_go/create_wip_suggestion_snapshot.txt @@ -0,0 +1,45 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create WIP task +Created new commit kkmpptxz 58a1a4cf (empty) [task:todo] WIP task +Created new commit k (empty) [task:todo] WIP task + +# after +○ kkmpptxz [task:todo] WIP task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:wip] WIP task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz a42f3902 (empty) [task:wip] WIP task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ kkmpptxz [task:wip] WIP task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent root() Other task + +Note: Current revision (k) is a WIP task. +Consider: `jjtask create "title"` to auto-chain from @ + +Created new commit kpqxywon abc47162 (empty) [task:todo] Other task +Created new commit kp (empty) [task:todo] Other task + +# after +@ kkmpptxz [task:wip] WIP task +○ qpvuntsm +│ ○ kpqxywon [task:todo] Other task +├─╯ +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/desc_transform.txt b/test/snapshots_go/desc_transform.txt new file mode 100644 index 0000000..afd9faf --- /dev/null +++ b/test/snapshots_go/desc_transform.txt @@ -0,0 +1,32 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Original title ## Context +Some context here +Created new commit kkmpptxz f40b40fc (empty) [task:todo] Original title +Created new commit k (empty) [task:todo] Original title + +# after +○ kkmpptxz [task:todo] Original title +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask desc-transform --rev k sed s/Original/Modified/ + +# after +○ kkmpptxz [task:todo] Modified title +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask show-desc --rev k +[task:todo] Modified title + +## Context +Some context here + +# after +○ kkmpptxz [task:todo] Modified title +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/desc_transform_error.txt b/test/snapshots_go/desc_transform_error.txt new file mode 100644 index 0000000..857faa1 --- /dev/null +++ b/test/snapshots_go/desc_transform_error.txt @@ -0,0 +1,16 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Test title +Created new commit kkmpptxz 8f4e8450 (empty) [task:todo] Test title +Created new commit k (empty) [task:todo] Test title + +# after +○ kkmpptxz [task:todo] Test title +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask desc-transform --rev k nonexistent-cmd-xyz +Error: command "nonexistent-cmd-xyz" not found + diff --git a/test/snapshots_go/done_empty_task.txt b/test/snapshots_go/done_empty_task.txt new file mode 100644 index 0000000..30cd5b9 --- /dev/null +++ b/test/snapshots_go/done_empty_task.txt @@ -0,0 +1,38 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Empty planning task +Created new commit kkmpptxz 41634cf4 (empty) [task:todo] Empty planning task +Created new commit k (empty) [task:todo] Empty planning task + +# after +○ kkmpptxz [task:todo] Empty planning task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask wip k +Rebased 1 commits to destination +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm ebcd0fc4 (empty) (no description set) +Parent commit (@-) : kkmpptxz 5beb15e2 (empty) [task:wip] Empty planning task + +# after +@ qpvuntsm +○ kkmpptxz [task:wip] Empty planning task +◆ zzzzzzzz root() 00000000 + +$ jjtask done k + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm 5e7e6331 (empty) (no description set) +Parent commit (@-) : kkmpptxz 84c1143e (empty) [task:done] Empty planning task + +# after +@ qpvuntsm +○ kkmpptxz [task:done] Empty planning task +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/done_linearizes_from_merge.txt b/test/snapshots_go/done_linearizes_from_merge.txt new file mode 100644 index 0000000..a01abb8 --- /dev/null +++ b/test/snapshots_go/done_linearizes_from_merge.txt @@ -0,0 +1,86 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base commit +Working copy (@) now at: qpvuntsm 25687f26 Base commit +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task A +Created new commit zsuskuln df92532a (empty) [task:todo] Task A +Created new commit zs (empty) [task:todo] Task A + +# after +○ zsuskuln [task:todo] Task A +@ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task B +Created new commit yostqsxw e74c637e (empty) [task:todo] Task B +Created new commit y (empty) [task:todo] Task B + +# after +○ yostqsxw [task:todo] Task B +│ ○ zsuskuln [task:todo] Task A +├─╯ +@ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask wip zs +Rebased 1 commits to destination +Rebased 2 descendant commits +Working copy (@) now at: qpvuntsm bb11f24b Base commit +Parent commit (@-) : zsuskuln 2255d8a3 (empty) [task:wip] Task A + +# after +@ qpvuntsm Base commit +○ zsuskuln [task:wip] Task A +│ ○ yostqsxw [task:todo] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask wip y +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm ecf240f9 Base commit +Parent commit (@-) : zsuskuln 2255d8a3 (empty) [task:wip] Task A +Parent commit (@-) : yostqsxw c0a169e1 (empty) [task:wip] Task B + +# after +@ qpvuntsm Base commit +├─╮ +│ ○ yostqsxw [task:wip] Task B +○ │ zsuskuln [task:wip] Task A +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask done zs + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + + +⚠️ Working copy (@) has uncommitted changes: +base.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) +Were any of these changes part of this task? + +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm dc0704d8 Base commit +Parent commit (@-) : zsuskuln 9f4e33c1 (empty) [task:done] Task A +Parent commit (@-) : yostqsxw c0a169e1 (empty) [task:wip] Task B +Rebased 2 commits to destination +Working copy (@) now at: qpvuntsm c8739067 Base commit +Parent commit (@-) : zsuskuln 9f4e33c1 (empty) [task:done] Task A +Parent commit (@-) : yostqsxw f7f66ba4 (empty) [task:wip] Task B +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 043f2b71 Base commit +Parent commit (@-) : yostqsxw f7f66ba4 (empty) [task:wip] Task B + +# after +@ qpvuntsm Base commit +○ yostqsxw [task:wip] Task B +○ zsuskuln [task:done] Task A +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/done_multiple_revs_at_once.txt b/test/snapshots_go/done_multiple_revs_at_once.txt new file mode 100644 index 0000000..e1b5503 --- /dev/null +++ b/test/snapshots_go/done_multiple_revs_at_once.txt @@ -0,0 +1,92 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task A +Created new commit zsuskuln ddccb27f (empty) [task:todo] Task A +Created new commit zs (empty) [task:todo] Task A + +# after +○ zsuskuln [task:todo] Task A +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jj edit zs +Working copy (@) now at: zsuskuln ddccb27f (empty) [task:todo] Task A +Parent commit (@-) : qpvuntsm 7dea31c7 Base + +# wrote a.txt (1 bytes) + +# before +@ zsuskuln [task:todo] Task A +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task B +Created new commit yostqsxw e3f02a31 (empty) [task:todo] Task B +Created new commit y (empty) [task:todo] Task B + +# after +○ yostqsxw [task:todo] Task B +@ zsuskuln [task:todo] Task A +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jj edit y +Working copy (@) now at: yostqsxw e3f02a31 (empty) [task:todo] Task B +Parent commit (@-) : zsuskuln 6f2c97de [task:todo] Task A + +# wrote b.txt (1 bytes) + +# before +@ yostqsxw [task:todo] Task B +○ zsuskuln [task:todo] Task A +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip zs +Rebased 1 descendant commits +Working copy (@) now at: yostqsxw b1e1890c [task:todo] Task B +Parent commit (@-) : zsuskuln 2c1ca4bf [task:wip] Task A + +# after +@ yostqsxw [task:todo] Task B +○ zsuskuln [task:wip] Task A +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip y +Working copy (@) now at: yostqsxw 3b8a9058 [task:wip] Task B +Parent commit (@-) : zsuskuln 2c1ca4bf [task:wip] Task A + +# after +@ yostqsxw [task:wip] Task B +○ zsuskuln [task:wip] Task A +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask done zs y + +⚠️ Working copy (@) has uncommitted changes: +b.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) +Were any of these changes part of this task? + +Rebased 1 descendant commits +Working copy (@) now at: yostqsxw bafc6a23 [task:wip] Task B +Parent commit (@-) : zsuskuln f0c4b129 [task:done] Task A +Working copy (@) now at: yostqsxw 2a0e3dcc [task:done] Task B +Parent commit (@-) : zsuskuln f0c4b129 [task:done] Task A + +# after +@ yostqsxw [task:done] Task B +○ zsuskuln [task:done] Task A +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/done_orphan_warning.txt b/test/snapshots_go/done_orphan_warning.txt new file mode 100644 index 0000000..667bdf9 --- /dev/null +++ b/test/snapshots_go/done_orphan_warning.txt @@ -0,0 +1,68 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create Orphan task +Created new commit zsuskuln 530441c1 (empty) [task:todo] Orphan task +Created new commit zs (empty) [task:todo] Orphan task + +# after +○ zsuskuln [task:todo] Orphan task +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jj new -m Working commit +Working copy (@) now at: yqosqzyt 83d4c2e8 (empty) Working commit +Parent commit (@-) : qpvuntsm 7dea31c7 Base + +# wrote work.txt (11 bytes) + +$ jj status +Working copy changes: +A work.txt +Working copy (@) : yqosqzyt e59fee1c Working commit +Parent commit (@-): qpvuntsm 7dea31c7 Base + +# before +@ yqosqzyt Working commit +│ ○ zsuskuln [task:todo] Orphan task +├─╯ +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask done zs + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + + +⚠️ Working copy (@) has uncommitted changes: +work.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) +Were any of these changes part of this task? + + +Warning: zs marked done but not in @'s ancestry (orphan tasks) +These tasks were never 'wip' - their specs won't be in linear history. + +Options: + 1. Consolidate specs into @ description, then abandon tasks + 2. Linearize into ancestry (may conflict) + 3. Leave as-is (manual cleanup later) + +View specs: jj log -r 'zs' --no-graph -T description + + +# after +@ yqosqzyt Working commit +│ ○ zsuskuln [task:done] Orphan task +├─╯ +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/done_single_parent_no_linearization.txt b/test/snapshots_go/done_single_parent_no_linearization.txt new file mode 100644 index 0000000..afc3cb5 --- /dev/null +++ b/test/snapshots_go/done_single_parent_no_linearization.txt @@ -0,0 +1,60 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task +Created new commit zsuskuln 374db492 (empty) [task:todo] Task +Created new commit zs (empty) [task:todo] Task + +# after +○ zsuskuln [task:todo] Task +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jj edit zs +Working copy (@) now at: zsuskuln 374db492 (empty) [task:todo] Task +Parent commit (@-) : qpvuntsm 7dea31c7 Base + +# wrote task.txt (7 bytes) + +# before +@ zsuskuln [task:todo] Task +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip +Working copy (@) now at: zsuskuln 43311e40 [task:wip] Task +Parent commit (@-) : qpvuntsm 7dea31c7 Base + +# after +@ zsuskuln [task:wip] Task +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jj new +Working copy (@) now at: kpqxywon 9dd85687 (empty) (no description set) +Parent commit (@-) : zsuskuln 43311e40 [task:wip] Task + +# before +@ kpqxywon +○ zsuskuln [task:wip] Task +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask done zs +Rebased 1 descendant commits +Working copy (@) now at: kpqxywon 0322a854 (empty) (no description set) +Parent commit (@-) : zsuskuln 02322779 [task:done] Task + +# after +@ kpqxywon +○ zsuskuln [task:done] Task +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/done_three_way_merge_linearizes.txt b/test/snapshots_go/done_three_way_merge_linearizes.txt new file mode 100644 index 0000000..92db10b --- /dev/null +++ b/test/snapshots_go/done_three_way_merge_linearizes.txt @@ -0,0 +1,128 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task A +Created new commit mzvwutvl 7bcc46b5 (empty) [task:todo] Task A +Created new commit m (empty) [task:todo] Task A + +# after +○ mzvwutvl [task:todo] Task A +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task B +Created new commit yostqsxw f0a1d0a8 (empty) [task:todo] Task B +Created new commit y (empty) [task:todo] Task B + +# after +○ yostqsxw [task:todo] Task B +│ ○ mzvwutvl [task:todo] Task A +├─╯ +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task C +Created new commit wqnwkozp 3ecfbba1 (empty) [task:todo] Task C +Created new commit w (empty) [task:todo] Task C + +# after +○ wqnwkozp [task:todo] Task C +│ ○ yostqsxw [task:todo] Task B +├─╯ +│ ○ mzvwutvl [task:todo] Task A +├─╯ +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip m +Rebased 1 commits to destination +Rebased 3 descendant commits +Working copy (@) now at: qpvuntsm c2b80b78 Base +Parent commit (@-) : mzvwutvl 8cbfb6a0 (empty) [task:wip] Task A + +# after +@ qpvuntsm Base +○ mzvwutvl [task:wip] Task A +│ ○ wqnwkozp [task:todo] Task C +├─╯ +│ ○ yostqsxw [task:todo] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask wip y +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 15b1b5d4 Base +Parent commit (@-) : mzvwutvl 8cbfb6a0 (empty) [task:wip] Task A +Parent commit (@-) : yostqsxw af6bd08c (empty) [task:wip] Task B + +# after +@ qpvuntsm Base +├─╮ +│ ○ yostqsxw [task:wip] Task B +○ │ mzvwutvl [task:wip] Task A +├─╯ +│ ○ wqnwkozp [task:todo] Task C +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask wip w +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 5c96f2f6 Base +Parent commit (@-) : yostqsxw af6bd08c (empty) [task:wip] Task B +Parent commit (@-) : mzvwutvl 8cbfb6a0 (empty) [task:wip] Task A +Parent commit (@-) : wqnwkozp 85fbb7db (empty) [task:wip] Task C + +# after +@ qpvuntsm Base +├─┬─╮ +│ │ ○ wqnwkozp [task:wip] Task C +│ ○ │ mzvwutvl [task:wip] Task A +│ ├─╯ +○ │ yostqsxw [task:wip] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask done m + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + + +⚠️ Working copy (@) has uncommitted changes: +base.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) +Were any of these changes part of this task? + +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm d6661b6a Base +Parent commit (@-) : yostqsxw af6bd08c (empty) [task:wip] Task B +Parent commit (@-) : mzvwutvl 194d71a7 (empty) [task:done] Task A +Parent commit (@-) : wqnwkozp 85fbb7db (empty) [task:wip] Task C +Rebased 2 commits to destination +Working copy (@) now at: qpvuntsm 30cc3572 Base +Parent commit (@-) : yostqsxw af6bd08c (empty) [task:wip] Task B +Parent commit (@-) : mzvwutvl 194d71a7 (empty) [task:done] Task A +Parent commit (@-) : wqnwkozp e7855d52 (empty) [task:wip] Task C +Rebased 2 commits to destination +Working copy (@) now at: qpvuntsm a09017bd Base +Parent commit (@-) : yostqsxw 59718c21 (empty) [task:wip] Task B +Parent commit (@-) : mzvwutvl 194d71a7 (empty) [task:done] Task A +Parent commit (@-) : wqnwkozp e7855d52 (empty) [task:wip] Task C +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 1e59a31d Base +Parent commit (@-) : yostqsxw 59718c21 (empty) [task:wip] Task B + +# after +@ qpvuntsm Base +○ yostqsxw [task:wip] Task B +○ wqnwkozp [task:wip] Task C +○ mzvwutvl [task:done] Task A +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/done_when_at_is_task.txt b/test/snapshots_go/done_when_at_is_task.txt new file mode 100644 index 0000000..2bac53b --- /dev/null +++ b/test/snapshots_go/done_when_at_is_task.txt @@ -0,0 +1,42 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task +Created new commit kkmpptxz 7ec0743e (empty) [task:todo] Task +Created new commit k (empty) [task:todo] Task + +# after +○ kkmpptxz [task:todo] Task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz 7ec0743e (empty) [task:todo] Task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# wrote work.txt (7 bytes) + +# before +@ kkmpptxz [task:todo] Task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask wip +Working copy (@) now at: kkmpptxz 07f0a638 [task:wip] Task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# after +@ kkmpptxz [task:wip] Task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask done +Working copy (@) now at: kkmpptxz abb7d888 [task:done] Task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# after +@ kkmpptxz [task:done] Task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/done_with_content.txt b/test/snapshots_go/done_with_content.txt new file mode 100644 index 0000000..9681f52 --- /dev/null +++ b/test/snapshots_go/done_with_content.txt @@ -0,0 +1,48 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task with content +Created new commit kkmpptxz 479d48ee (empty) [task:todo] Task with content +Created new commit k (empty) [task:todo] Task with content + +# after +○ kkmpptxz [task:todo] Task with content +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz 479d48ee (empty) [task:todo] Task with content +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# wrote workfile.txt (11 bytes) + +$ jj status +Working copy changes: +A workfile.txt +Working copy (@) : kkmpptxz bd599583 [task:todo] Task with content +Parent commit (@-): qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ kkmpptxz [task:todo] Task with content +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask wip +Working copy (@) now at: kkmpptxz 4cf252c3 [task:wip] Task with content +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# after +@ kkmpptxz [task:wip] Task with content +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask done +Working copy (@) now at: kkmpptxz ad7b5d7d [task:done] Task with content +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# after +@ kkmpptxz [task:done] Task with content +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/drop_abandon.txt b/test/snapshots_go/drop_abandon.txt new file mode 100644 index 0000000..403f951 --- /dev/null +++ b/test/snapshots_go/drop_abandon.txt @@ -0,0 +1,25 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task to abandon +Created new commit kkmpptxz 43aa9ddd (empty) [task:todo] Task to abandon +Created new commit k (empty) [task:todo] Task to abandon + +# after +○ kkmpptxz [task:todo] Task to abandon +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask drop --abandon k +Abandoned 1 commits: + kkmpptxz 43aa9ddd (empty) [task:todo] Task to abandon +Abandoned k + +# after +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r k --no-graph +Error: Revision `k` doesn't exist + diff --git a/test/snapshots_go/drop_marks_standby.txt b/test/snapshots_go/drop_marks_standby.txt new file mode 100644 index 0000000..5befcca --- /dev/null +++ b/test/snapshots_go/drop_marks_standby.txt @@ -0,0 +1,35 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task to drop +Created new commit kkmpptxz e60b2e74 (empty) [task:todo] Task to drop +Created new commit k (empty) [task:todo] Task to drop + +# after +○ kkmpptxz [task:todo] Task to drop +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask wip k +Rebased 1 commits to destination +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm 6edd435d (empty) (no description set) +Parent commit (@-) : kkmpptxz fe5d054e (empty) [task:wip] Task to drop + +# after +@ qpvuntsm +○ kkmpptxz [task:wip] Task to drop +◆ zzzzzzzz root() 00000000 + +$ jjtask drop k +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm b4145ecd (empty) (no description set) +Parent commit (@-) : kkmpptxz f851f3c2 (empty) [task:standby] Task to drop +Marked k as standby + +# after +@ qpvuntsm +○ kkmpptxz [task:standby] Task to drop +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/drop_multiple_revs_at_once.txt b/test/snapshots_go/drop_multiple_revs_at_once.txt new file mode 100644 index 0000000..e3d1096 --- /dev/null +++ b/test/snapshots_go/drop_multiple_revs_at_once.txt @@ -0,0 +1,82 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task A +Created new commit mzvwutvl 7bcc46b5 (empty) [task:todo] Task A +Created new commit m (empty) [task:todo] Task A + +# after +○ mzvwutvl [task:todo] Task A +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task B +Created new commit yostqsxw f0a1d0a8 (empty) [task:todo] Task B +Created new commit y (empty) [task:todo] Task B + +# after +○ yostqsxw [task:todo] Task B +│ ○ mzvwutvl [task:todo] Task A +├─╯ +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip m +Rebased 1 commits to destination +Rebased 2 descendant commits +Working copy (@) now at: qpvuntsm 07fb65b4 Base +Parent commit (@-) : mzvwutvl 667b29ad (empty) [task:wip] Task A + +# after +@ qpvuntsm Base +○ mzvwutvl [task:wip] Task A +│ ○ yostqsxw [task:todo] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask wip y +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 83d9a821 Base +Parent commit (@-) : mzvwutvl 667b29ad (empty) [task:wip] Task A +Parent commit (@-) : yostqsxw c0a169e1 (empty) [task:wip] Task B + +# after +@ qpvuntsm Base +├─╮ +│ ○ yostqsxw [task:wip] Task B +○ │ mzvwutvl [task:wip] Task A +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask drop m y +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm a59a0442 Base +Parent commit (@-) : mzvwutvl 47b5a6d5 (empty) [task:standby] Task A +Parent commit (@-) : yostqsxw c0a169e1 (empty) [task:wip] Task B +Marked m as standby +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm 36f8674c (empty) Base +Parent commit (@-) : mzvwutvl 47b5a6d5 (empty) [task:standby] Task A +Parent commit (@-) : yostqsxw 248bccad [task:wip] Task B +Working copy (@) now at: yostqsxw 248bccad [task:wip] Task B +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) +Rebased 1 descendant commits +Working copy (@) now at: yostqsxw c6e24152 [task:standby] Task B +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) +Marked y as standby + +# after +○ qpvuntsm Base +├─╮ +│ @ yostqsxw [task:standby] Task B +○ │ mzvwutvl [task:standby] Task A +├─╯ +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/drop_preserves_at_content.txt b/test/snapshots_go/drop_preserves_at_content.txt new file mode 100644 index 0000000..bf9a526 --- /dev/null +++ b/test/snapshots_go/drop_preserves_at_content.txt @@ -0,0 +1,88 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task A +Created new commit mzvwutvl 7bcc46b5 (empty) [task:todo] Task A +Created new commit m (empty) [task:todo] Task A + +# after +○ mzvwutvl [task:todo] Task A +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task B +Created new commit yostqsxw f0a1d0a8 (empty) [task:todo] Task B +Created new commit y (empty) [task:todo] Task B + +# after +○ yostqsxw [task:todo] Task B +│ ○ mzvwutvl [task:todo] Task A +├─╯ +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip m +Rebased 1 commits to destination +Rebased 2 descendant commits +Working copy (@) now at: qpvuntsm 07fb65b4 Base +Parent commit (@-) : mzvwutvl 667b29ad (empty) [task:wip] Task A + +# after +@ qpvuntsm Base +○ mzvwutvl [task:wip] Task A +│ ○ yostqsxw [task:todo] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask wip y +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 83d9a821 Base +Parent commit (@-) : mzvwutvl 667b29ad (empty) [task:wip] Task A +Parent commit (@-) : yostqsxw c0a169e1 (empty) [task:wip] Task B + +# after +@ qpvuntsm Base +├─╮ +│ ○ yostqsxw [task:wip] Task B +○ │ mzvwutvl [task:wip] Task A +├─╯ +◆ zzzzzzzz root() 00000000 + +# wrote merge_work.txt (13 bytes) + +$ jj status +Working copy changes: +A base.txt +A merge_work.txt +Working copy (@) : qpvuntsm d904c296 Base +Parent commit (@-): mzvwutvl 667b29ad (empty) [task:wip] Task A +Parent commit (@-): yostqsxw c0a169e1 (empty) [task:wip] Task B + +$ jjtask drop m +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm 48157a6c Base +Parent commit (@-) : mzvwutvl ff37996e (empty) [task:standby] Task A +Parent commit (@-) : yostqsxw c0a169e1 (empty) [task:wip] Task B +Marked m as standby +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm a46610db (empty) Base +Parent commit (@-) : mzvwutvl ff37996e (empty) [task:standby] Task A +Parent commit (@-) : yostqsxw c5357caa [task:wip] Task B +Working copy (@) now at: yostqsxw c5357caa [task:wip] Task B +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# after +○ qpvuntsm Base +├─╮ +│ @ yostqsxw [task:wip] Task B +○ │ mzvwutvl [task:standby] Task A +├─╯ +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/find_revset_filters_tasks.txt b/test/snapshots_go/find_revset_filters_tasks.txt new file mode 100644 index 0000000..da8b404 --- /dev/null +++ b/test/snapshots_go/find_revset_filters_tasks.txt @@ -0,0 +1,36 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create My task +Created new commit kkmpptxz 971cf556 (empty) [task:todo] My task +Created new commit k (empty) [task:todo] My task + +# after +○ kkmpptxz [task:todo] My task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj new -m Regular commit +Working copy (@) now at: mzvwutvl 40071063 (empty) Regular commit +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ mzvwutvl Regular commit +│ ○ kkmpptxz [task:todo] My task +├─╯ +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find -r all() +○ kkmpptxz [task:todo] My task +│ +~ + +# after +@ mzvwutvl Regular commit +│ ○ kkmpptxz [task:todo] My task +├─╯ +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/find_shows_tasks.txt b/test/snapshots_go/find_shows_tasks.txt new file mode 100644 index 0000000..85e5bb4 --- /dev/null +++ b/test/snapshots_go/find_shows_tasks.txt @@ -0,0 +1,39 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task A +Created new commit kkmpptxz daaa8cef (empty) [task:todo] Task A +Created new commit k (empty) [task:todo] Task A + +# after +○ kkmpptxz [task:todo] Task A +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task B +Created new commit royxmykx 529cd844 (empty) [task:todo] Task B +Created new commit r (empty) [task:todo] Task B + +# after +○ royxmykx [task:todo] Task B +│ ○ kkmpptxz [task:todo] Task A +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find +○ royxmykx [task:todo] Task B +│ ○ kkmpptxz [task:todo] Task A +├─╯ +@ qpvuntsm +│ +~ + +# after +○ royxmykx [task:todo] Task B +│ ○ kkmpptxz [task:todo] Task A +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_done_no_warning_children_done.txt b/test/snapshots_go/flag_done_no_warning_children_done.txt new file mode 100644 index 0000000..b8d9909 --- /dev/null +++ b/test/snapshots_go/flag_done_no_warning_children_done.txt @@ -0,0 +1,52 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent task +Created new commit kkmpptxz 63208f86 (empty) [task:todo] Parent task +Created new commit k (empty) [task:todo] Parent task + +# after +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child task +Created new commit yqosqzyt 70b2bafa (empty) [task:todo] Child task +Created new commit y (empty) [task:todo] Child task + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r children(k) & tasks() --no-graph -T change_id.shortest() +y +$ jjtask flag done --rev y + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:done] Child task +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag done --rev k + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Rebased 1 descendant commits +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:done] Child task +○ kkmpptxz [task:done] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_done_no_warning_with_content.txt b/test/snapshots_go/flag_done_no_warning_with_content.txt new file mode 100644 index 0000000..09fd0e9 --- /dev/null +++ b/test/snapshots_go/flag_done_no_warning_with_content.txt @@ -0,0 +1,40 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task with work +Created new commit kkmpptxz eb44a192 (empty) [task:todo] Task with work +Created new commit k (empty) [task:todo] Task with work + +# after +○ kkmpptxz [task:todo] Task with work +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz eb44a192 (empty) [task:todo] Task with work +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# wrote workfile.txt (11 bytes) + +$ jj status +Working copy changes: +A workfile.txt +Working copy (@) : kkmpptxz ccdddada [task:todo] Task with work +Parent commit (@-): qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ kkmpptxz [task:todo] Task with work +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag done +Working copy (@) now at: kkmpptxz 7c2cee8b [task:done] Task with work +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +@ kkmpptxz [task:done] Task with work +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_done_warns_empty.txt b/test/snapshots_go/flag_done_warns_empty.txt new file mode 100644 index 0000000..3b9ff27 --- /dev/null +++ b/test/snapshots_go/flag_done_warns_empty.txt @@ -0,0 +1,25 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Empty task +Created new commit kkmpptxz e61999f2 (empty) [task:todo] Empty task +Created new commit k (empty) [task:todo] Empty task + +# after +○ kkmpptxz [task:todo] Empty task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag done --rev k + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:done] Empty task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_done_warns_pending_children.txt b/test/snapshots_go/flag_done_warns_pending_children.txt new file mode 100644 index 0000000..cf0ea13 --- /dev/null +++ b/test/snapshots_go/flag_done_warns_pending_children.txt @@ -0,0 +1,42 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent task +Created new commit kkmpptxz 63208f86 (empty) [task:todo] Parent task +Created new commit k (empty) [task:todo] Parent task + +# after +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child task +Created new commit yqosqzyt 70b2bafa (empty) [task:todo] Child task +Created new commit y (empty) [task:todo] Child task + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag done --rev k + +⚠️ Task has 1 pending children: + • y [task:todo] Child task +Consider marking children done first, or they may be orphaned. + + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Rebased 1 descendant commits +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:done] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_done_warns_when_at_has_diff.txt b/test/snapshots_go/flag_done_warns_when_at_has_diff.txt new file mode 100644 index 0000000..d5bbb1f --- /dev/null +++ b/test/snapshots_go/flag_done_warns_when_at_has_diff.txt @@ -0,0 +1,39 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task to complete +Created new commit kkmpptxz 882ee089 (empty) [task:todo] Task to complete +Created new commit k (empty) [task:todo] Task to complete + +# after +○ kkmpptxz [task:todo] Task to complete +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +# wrote workfile.txt (4 bytes) + +# before +Rebased 1 descendant commits onto updated working copy +○ kkmpptxz [task:todo] Task to complete +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag done --rev k + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + + +⚠️ Working copy (@) has uncommitted changes: +workfile.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) +Were any of these changes part of this task? + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:done] Task to complete +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_no_warning_when_at_is_task.txt b/test/snapshots_go/flag_no_warning_when_at_is_task.txt new file mode 100644 index 0000000..9fe1a07 --- /dev/null +++ b/test/snapshots_go/flag_no_warning_when_at_is_task.txt @@ -0,0 +1,34 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task to complete +Created new commit kkmpptxz 882ee089 (empty) [task:todo] Task to complete +Created new commit k (empty) [task:todo] Task to complete + +# after +○ kkmpptxz [task:todo] Task to complete +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz 882ee089 (empty) [task:todo] Task to complete +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# wrote workfile.txt (4 bytes) + +# before +@ kkmpptxz [task:todo] Task to complete +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag done +Working copy (@) now at: kkmpptxz 0ffc39b0 [task:done] Task to complete +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +@ kkmpptxz [task:done] Task to complete +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_no_warning_when_clean.txt b/test/snapshots_go/flag_no_warning_when_clean.txt new file mode 100644 index 0000000..a63974e --- /dev/null +++ b/test/snapshots_go/flag_no_warning_when_clean.txt @@ -0,0 +1,25 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task to complete +Created new commit kkmpptxz 882ee089 (empty) [task:todo] Task to complete +Created new commit k (empty) [task:todo] Task to complete + +# after +○ kkmpptxz [task:todo] Task to complete +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag done --rev k + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:done] Task to complete +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_updates_status.txt b/test/snapshots_go/flag_updates_status.txt new file mode 100644 index 0000000..62f2f6d --- /dev/null +++ b/test/snapshots_go/flag_updates_status.txt @@ -0,0 +1,32 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Test task +Created new commit kkmpptxz 5785c0b3 (empty) [task:todo] Test task +Created new commit k (empty) [task:todo] Test task + +# after +○ kkmpptxz [task:todo] Test task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:wip] Test task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find +○ kkmpptxz [task:wip] Test task +@ qpvuntsm +│ +~ + +# after +○ kkmpptxz [task:wip] Test task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_wip_no_warning_not_blocked.txt b/test/snapshots_go/flag_wip_no_warning_not_blocked.txt new file mode 100644 index 0000000..a3ed27c --- /dev/null +++ b/test/snapshots_go/flag_wip_no_warning_not_blocked.txt @@ -0,0 +1,34 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent task +Created new commit kkmpptxz 63208f86 (empty) [task:todo] Parent task +Created new commit k (empty) [task:todo] Parent task + +# after +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child task +Created new commit yqosqzyt 70b2bafa (empty) [task:todo] Child task +Created new commit y (empty) [task:todo] Child task + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r children(k) & tasks() --no-graph -T change_id.shortest() +y +$ jjtask flag wip --rev y +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:wip] Child task +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_wip_no_warning_same_chain.txt b/test/snapshots_go/flag_wip_no_warning_same_chain.txt new file mode 100644 index 0000000..55c849a --- /dev/null +++ b/test/snapshots_go/flag_wip_no_warning_same_chain.txt @@ -0,0 +1,44 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent task +Created new commit kkmpptxz 63208f86 (empty) [task:todo] Parent task +Created new commit k (empty) [task:todo] Parent task + +# after +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child task +Created new commit yqosqzyt 70b2bafa (empty) [task:todo] Child task +Created new commit y (empty) [task:todo] Child task + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r children(k) & tasks() --no-graph -T change_id.shortest() +y +$ jjtask flag wip --rev k +Rebased 1 descendant commits +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:wip] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev y +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:wip] Child task +○ kkmpptxz [task:wip] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_wip_warns_blocked_ancestor.txt b/test/snapshots_go/flag_wip_warns_blocked_ancestor.txt new file mode 100644 index 0000000..8be8b4f --- /dev/null +++ b/test/snapshots_go/flag_wip_warns_blocked_ancestor.txt @@ -0,0 +1,47 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent task +Created new commit kkmpptxz 63208f86 (empty) [task:todo] Parent task +Created new commit k (empty) [task:todo] Parent task + +# after +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag blocked --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:blocked] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child task +Created new commit znkkpsqq 04688420 (empty) [task:todo] Child task +Created new commit zn (empty) [task:todo] Child task + +# after +○ znkkpsqq [task:todo] Child task +○ kkmpptxz [task:blocked] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r children(k) & tasks() --no-graph -T change_id.shortest() +zn +$ jjtask flag wip --rev zn + +⚠️ Ancestor task is blocked: + • k [task:blocked] Parent task +Consider unblocking the ancestor first. + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ znkkpsqq [task:wip] Child task +○ kkmpptxz [task:blocked] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_wip_warns_done_ancestor.txt b/test/snapshots_go/flag_wip_warns_done_ancestor.txt new file mode 100644 index 0000000..70e88ef --- /dev/null +++ b/test/snapshots_go/flag_wip_warns_done_ancestor.txt @@ -0,0 +1,60 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Parent task +Created new commit kkmpptxz 63208f86 (empty) [task:todo] Parent task +Created new commit k (empty) [task:todo] Parent task + +# after +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent k Child task +Created new commit yqosqzyt 70b2bafa (empty) [task:todo] Child task +Created new commit y (empty) [task:todo] Child task + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:todo] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r children(k) & tasks() --no-graph -T change_id.shortest() +y +$ jjtask flag done --rev k + +⚠️ Task has 1 pending children: + • y [task:todo] Child task +Consider marking children done first, or they may be orphaned. + + +⚠️ Task is empty - no changes to mark done +If this is a planning-only task, this warning can be ignored. + +Rebased 1 descendant commits +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:todo] Child task +○ kkmpptxz [task:done] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev y + +⚠️ Ancestor task is already done: + • k [task:done] Parent task +Starting work below done tasks is unusual. Consider: + • Rebase as sibling: jj rebase -s y -d <done-task>~ + • Or squash done tasks: jjtask squash -r <done-task> + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ yqosqzyt [task:wip] Child task +○ kkmpptxz [task:done] Parent task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_wip_warns_existing_wip.txt b/test/snapshots_go/flag_wip_warns_existing_wip.txt new file mode 100644 index 0000000..f0e301a --- /dev/null +++ b/test/snapshots_go/flag_wip_warns_existing_wip.txt @@ -0,0 +1,56 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task A +Created new commit kkmpptxz daaa8cef (empty) [task:todo] Task A +Created new commit k (empty) [task:todo] Task A + +# after +○ kkmpptxz [task:todo] Task A +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent @ Task B +Created new commit royxmykx 529cd844 (empty) [task:todo] Task B +Created new commit r (empty) [task:todo] Task B + +# after +○ royxmykx [task:todo] Task B +│ ○ kkmpptxz [task:todo] Task A +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj log -r tasks() & description(substring:"Task A") --no-graph -T change_id.shortest() +k +$ jj log -r tasks() & description(substring:"Task B") --no-graph -T change_id.shortest() +r +$ jjtask flag wip --rev k +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:wip] Task A +│ ○ royxmykx [task:todo] Task B +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev r + +⚠️ Another WIP task exists: k [task:wip] Task A +Multiple WIP tasks in different branches can be confusing. +Options: + • Pause existing: jjtask flag blocked -r k + • Switch to existing: jj edit k + • Rebase to chain: jj rebase -s r -d k + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ royxmykx [task:wip] Task B +│ ○ kkmpptxz [task:wip] Task A +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/flag_wip_warns_when_at_has_diff.txt b/test/snapshots_go/flag_wip_warns_when_at_has_diff.txt new file mode 100644 index 0000000..1dc1691 --- /dev/null +++ b/test/snapshots_go/flag_wip_warns_when_at_has_diff.txt @@ -0,0 +1,35 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task to start +Created new commit kkmpptxz 0f9189c6 (empty) [task:todo] Task to start +Created new commit k (empty) [task:todo] Task to start + +# after +○ kkmpptxz [task:todo] Task to start +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +# wrote workfile.txt (4 bytes) + +# before +Rebased 1 descendant commits onto updated working copy +○ kkmpptxz [task:todo] Task to start +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask flag wip --rev k + +⚠️ Working copy (@) has uncommitted changes: +workfile.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) +Were any of these changes part of this task? + +Tip: Consider using 'jjtask wip' or 'jjtask done' for the mega-merge workflow + +# after +○ kkmpptxz [task:wip] Task to start +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/multi_repo_all_log.txt b/test/snapshots_go/multi_repo_all_log.txt new file mode 100644 index 0000000..04f76b6 --- /dev/null +++ b/test/snapshots_go/multi_repo_all_log.txt @@ -0,0 +1,46 @@ +=== root === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask all log -r @ +migrated .jj-workspaces.yaml → .jjtask.toml +cwd: . | repo: root + +=== frontend: jj -R ./frontend log === +@ qpvuntsm test.user@example.com 2001-02-03 04:05:08 292b4cf8 +│ Frontend commit +~ + +=== backend: jj -R ./backend log === +@ qpvuntsm test.user@example.com 2001-02-03 04:05:08 b704ba43 +│ Backend commit +~ + +=== root: jj -R . log === +@ qpvuntsm test.user@example.com 2001-02-03 04:05:09 6d42bc2e +│ (no description set) +~ + + +# after +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + +=== frontend === +# wrote file.txt (4 bytes) + +$ jj describe -m Frontend commit +Working copy (@) now at: qpvuntsm 292b4cf8 Frontend commit +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + + +=== backend === +# wrote file.txt (4 bytes) + +$ jj describe -m Backend commit +Working copy (@) now at: qpvuntsm b704ba43 Backend commit +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + + diff --git a/test/snapshots_go/multi_repo_complex.txt b/test/snapshots_go/multi_repo_complex.txt new file mode 100644 index 0000000..267f488 --- /dev/null +++ b/test/snapshots_go/multi_repo_complex.txt @@ -0,0 +1,170 @@ +=== root === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create ROOT: CI/CD pipeline +Created new commit kkmpptxz 5776b840 (empty) [task:todo] ROOT: CI/CD pipeline +Created new commit k (empty) [task:todo] ROOT: CI/CD pipeline + +# after +○ kkmpptxz [task:todo] ROOT: CI/CD pipeline +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --draft @ ROOT: Terraform modules +Created new commit royxmykx 96216717 (empty) [task:draft] @ +Created new commit r (empty) [task:draft] @ + +# after +○ royxmykx [task:draft] @ +│ ○ kkmpptxz [task:todo] ROOT: CI/CD pipeline +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create ROOT: Integration tests +Created new commit yostqsxw 2a30311b (empty) [task:todo] ROOT: Integration tests +Created new commit y (empty) [task:todo] ROOT: Integration tests + +# after +○ yostqsxw [task:todo] ROOT: Integration tests +│ ○ royxmykx [task:draft] @ +├─╯ +│ ○ kkmpptxz [task:todo] ROOT: CI/CD pipeline +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find --status all +migrated .jj-workspaces.yaml → .jjtask.toml +cwd: . | repo: root + +=== frontend: jj -R ./frontend log === +○ yostqsxw [task:todo] FE: Error boundaries +│ +~ + +○ royxmykx [task:draft] @ +│ FE: Dark mode toggle +~ + +○ kkmpptxz [task:todo] FE: Auth login page +│ +~ + +=== backend: jj -R ./backend log === +○ yostqsxw [task:todo] BE: Background jobs +│ +~ + +○ royxmykx [task:draft] @ +│ BE: GraphQL schema +~ + +○ kkmpptxz [task:todo] BE: User API endpoints +│ +~ + +=== root: jj -R . log === +○ yostqsxw [task:todo] ROOT: Integration tests +│ +~ + +○ royxmykx [task:draft] @ +│ ROOT: Terraform modules +~ + +○ kkmpptxz [task:todo] ROOT: CI/CD pipeline +│ +~ + + +# after +○ yostqsxw [task:todo] ROOT: Integration tests +│ ○ royxmykx [task:draft] @ +├─╯ +│ ○ kkmpptxz [task:todo] ROOT: CI/CD pipeline +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + +=== frontend === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create FE: Auth login page +Created new commit kkmpptxz 5b5ebcae (empty) [task:todo] FE: Auth login page +Created new commit k (empty) [task:todo] FE: Auth login page + +# after +○ kkmpptxz [task:todo] FE: Auth login page +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --draft @ FE: Dark mode toggle +Created new commit royxmykx d37fb595 (empty) [task:draft] @ +Created new commit r (empty) [task:draft] @ + +# after +○ royxmykx [task:draft] @ +│ ○ kkmpptxz [task:todo] FE: Auth login page +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create FE: Error boundaries +Created new commit yostqsxw 3370f8bf (empty) [task:todo] FE: Error boundaries +Created new commit y (empty) [task:todo] FE: Error boundaries + +# after +○ yostqsxw [task:todo] FE: Error boundaries +│ ○ royxmykx [task:draft] @ +├─╯ +│ ○ kkmpptxz [task:todo] FE: Auth login page +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + +=== backend === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create BE: User API endpoints +Created new commit kkmpptxz 059fdf74 (empty) [task:todo] BE: User API endpoints +Created new commit k (empty) [task:todo] BE: User API endpoints + +# after +○ kkmpptxz [task:todo] BE: User API endpoints +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --draft @ BE: GraphQL schema +Created new commit royxmykx 516f5850 (empty) [task:draft] @ +Created new commit r (empty) [task:draft] @ + +# after +○ royxmykx [task:draft] @ +│ ○ kkmpptxz [task:todo] BE: User API endpoints +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create BE: Background jobs +Created new commit yostqsxw 2b988d15 (empty) [task:todo] BE: Background jobs +Created new commit y (empty) [task:todo] BE: Background jobs + +# after +○ yostqsxw [task:todo] BE: Background jobs +│ ○ royxmykx [task:draft] @ +├─╯ +│ ○ kkmpptxz [task:todo] BE: User API endpoints +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + diff --git a/test/snapshots_go/multi_repo_config_migration.txt b/test/snapshots_go/multi_repo_config_migration.txt new file mode 100644 index 0000000..d6896d8 --- /dev/null +++ b/test/snapshots_go/multi_repo_config_migration.txt @@ -0,0 +1,30 @@ +=== root === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find +migrated .jj-workspaces.yaml → .jjtask.toml +cwd: . | repo: root + +=== frontend: jj -R ./frontend log === +@ qpvuntsm +│ +~ + +=== backend: jj -R ./backend log === +@ qpvuntsm +│ +~ + +=== root: jj -R . log === +@ qpvuntsm +│ .jjtask.toml | 6 ++++++ +~ 1 file changed, 6 insertions(+), 0 deletions(-) + + +# after +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + diff --git a/test/snapshots_go/multi_repo_find.txt b/test/snapshots_go/multi_repo_find.txt new file mode 100644 index 0000000..b5cc480 --- /dev/null +++ b/test/snapshots_go/multi_repo_find.txt @@ -0,0 +1,62 @@ +=== root === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find +migrated .jj-workspaces.yaml → .jjtask.toml +cwd: . | repo: root + +=== frontend: jj -R ./frontend log === +○ kkmpptxz [task:todo] Frontend task +@ qpvuntsm +│ +~ + +=== backend: jj -R ./backend log === +○ kkmpptxz [task:todo] Backend task +@ qpvuntsm +│ +~ + +=== root: jj -R . log === +@ qpvuntsm +│ .jjtask.toml | 6 ++++++ +~ 1 file changed, 6 insertions(+), 0 deletions(-) + + +# after +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + +=== frontend === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Frontend task +Created new commit kkmpptxz 87519c62 (empty) [task:todo] Frontend task +Created new commit k (empty) [task:todo] Frontend task + +# after +○ kkmpptxz [task:todo] Frontend task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + +=== backend === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Backend task +Created new commit kkmpptxz dd1114cf (empty) [task:todo] Backend task +Created new commit k (empty) [task:todo] Backend task + +# after +○ kkmpptxz [task:todo] Backend task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + diff --git a/test/snapshots_go/multi_repo_workspace_hint.txt b/test/snapshots_go/multi_repo_workspace_hint.txt new file mode 100644 index 0000000..ce73ff3 --- /dev/null +++ b/test/snapshots_go/multi_repo_workspace_hint.txt @@ -0,0 +1,41 @@ +=== frontend === +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Frontend task +Created new commit kkmpptxz 87519c62 (empty) [task:todo] Frontend task +Created new commit k (empty) [task:todo] Frontend task + +# after +○ kkmpptxz [task:todo] Frontend task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find +migrated .jj-workspaces.yaml → .jjtask.toml +cwd: frontend/src | repo: frontend | workspace: ../.. + +=== frontend: jj -R .. log === +○ kkmpptxz [task:todo] Frontend task +@ qpvuntsm +│ +~ + +=== backend: jj -R ../../backend log === +@ qpvuntsm +│ +~ + +=== root: jj -R ../.. log === +@ qpvuntsm +│ ../../.jjtask.toml | 6 ++++++ +~ 1 file changed, 6 insertions(+), 0 deletions(-) + + +# after +○ kkmpptxz [task:todo] Frontend task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + + diff --git a/test/snapshots_go/parallel_creates_siblings.txt b/test/snapshots_go/parallel_creates_siblings.txt new file mode 100644 index 0000000..1172618 --- /dev/null +++ b/test/snapshots_go/parallel_creates_siblings.txt @@ -0,0 +1,38 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask parallel Task A Task B Task C +Created new commit kkmpptxz daaa8cef (empty) [task:todo] Task A +Created new commit kkmpptxz/0 b5cdc105 (divergent) (empty) [task:todo] Task B +Created new commit kkmpptxz/0 165b7089 (divergent) (empty) [task:todo] Task C +Created 3 parallel task branches from @ + +# after +○ kkmpptxz [task:todo] Task C +│ ○ kkmpptxz [task:todo] Task B +├─╯ +│ ○ kkmpptxz [task:todo] Task A +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask find +○ kkmpptxz [task:todo] Task C +│ ○ kkmpptxz [task:todo] Task B +├─╯ +│ ○ kkmpptxz [task:todo] Task A +├─╯ +@ qpvuntsm +│ +~ + +# after +○ kkmpptxz [task:todo] Task C +│ ○ kkmpptxz [task:todo] Task B +├─╯ +│ ○ kkmpptxz [task:todo] Task A +├─╯ +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/prime.txt b/test/snapshots_go/prime.txt new file mode 100644 index 0000000..d27abba --- /dev/null +++ b/test/snapshots_go/prime.txt @@ -0,0 +1,66 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask prime + +## JJ TASK Quick Reference + +Task flags: draft → todo → wip → done (also: blocked, standby, untested, review) + +### Revsets +tasks(), tasks_pending(), tasks_todo(), tasks_wip(), tasks_done(), tasks_blocked() + +### Commands (all support -R, --quiet, etc.) +jjtask create TITLE [-p REV] Create task as child of @ (or REV) +jjtask wip [TASKS...] Mark WIP, add as parents of @ +jjtask done [TASKS...] Mark done, linearize into ancestry +jjtask drop TASKS... [--abandon] Remove from @ (standby or abandon) +jjtask squash Flatten @ merge for push +jjtask find [-s STATUS] [-r REVSET] List tasks (status: todo/wip/done/all) +jjtask flag STATUS [-r REV] Change task flag (defaults to @) +jjtask parallel T1 T2... [-p REV] Create sibling tasks (defaults to @) +jjtask show-desc [-r REV] Print revision description +jjtask desc-transform CMD [-r REV] Transform description with command +jjtask checkpoint [-m MSG] Create checkpoint commit +jjtask stale Find done tasks not in @'s ancestry +jjtask all CMD [ARGS] Run jj CMD across workspaces + +### Workflow +1. `jjtask create 'task'` # Plan tasks +2. `jjtask wip TASK` # Start (single=edit, multi=merge) +3. `jj edit TASK` to work # Work directly in task branch +4. `jjtask done` # Complete, linearizes into ancestry +5. `jjtask squash` # Flatten for push + +Key: @ is merge of WIP tasks. Work in task branches directly. +For merge: `jj edit TASK`, not bare `jj absorb`. + +### Rules +- DAG = priority: parent tasks complete before children +- Chain related tasks: `jjtask create --chain 'Next step'` +- Read full spec before editing - descriptions are specifications +- Never mark done unless ALL acceptance criteria pass +- Use `jjtask flag review/blocked/untested` if incomplete +- Stop and report if unsure - don't attempt JJ recovery ops + +### Before Saying Done +[ ] All acceptance criteria in task spec pass +[ ] `jjtask done TASK` - mark complete +[ ] `jjtask squash` - flatten for push when ready + +### Native Task Tools +TaskCreate, TaskUpdate, TaskList, TaskGet - for session workflow tracking +Use for: multi-step work within a session, dependency ordering, progress display +jjtask = persistent tasks in repo history; Task* = ephemeral session tracking + +### Current Tasks + +No tasks. Create one with: jjtask create 'Task title' + +### Changes (0 files +0 -0) + +# after +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/show_desc.txt b/test/snapshots_go/show_desc.txt new file mode 100644 index 0000000..24b8049 --- /dev/null +++ b/test/snapshots_go/show_desc.txt @@ -0,0 +1,32 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Test title Test body content +Created new commit kkmpptxz 74bfb50c (empty) [task:todo] Test title +Created new commit k (empty) [task:todo] Test title + +# after +○ kkmpptxz [task:todo] Test title +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz 74bfb50c (empty) [task:todo] Test title +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ kkmpptxz [task:todo] Test title +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask show-desc +[task:todo] Test title + +Test body content + +# after +@ kkmpptxz [task:todo] Test title +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/squash_flatten.txt b/test/snapshots_go/squash_flatten.txt new file mode 100644 index 0000000..3cc7c56 --- /dev/null +++ b/test/snapshots_go/squash_flatten.txt @@ -0,0 +1,104 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base commit +Working copy (@) now at: qpvuntsm 25687f26 Base commit +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task A +Created new commit zsuskuln df92532a (empty) [task:todo] Task A +Created new commit zs (empty) [task:todo] Task A + +# after +○ zsuskuln [task:todo] Task A +@ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jj edit zs +Working copy (@) now at: zsuskuln df92532a (empty) [task:todo] Task A +Parent commit (@-) : qpvuntsm 25687f26 Base commit + +# wrote file_a.txt (9 bytes) + +$ jj status +Working copy changes: +A file_a.txt +Working copy (@) : zsuskuln ae2c5e9e [task:todo] Task A +Parent commit (@-): qpvuntsm 25687f26 Base commit + +# before +@ zsuskuln [task:todo] Task A +○ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task B +Created new commit kpqxywon b297fedb (empty) [task:todo] Task B +Created new commit k (empty) [task:todo] Task B + +# after +@ zsuskuln [task:todo] Task A +│ ○ kpqxywon [task:todo] Task B +├─╯ +○ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kpqxywon b297fedb (empty) [task:todo] Task B +Parent commit (@-) : qpvuntsm 25687f26 Base commit +Added 0 files, modified 0 files, removed 1 files + +# wrote file_b.txt (9 bytes) + +$ jj status +Working copy changes: +A file_b.txt +Working copy (@) : kpqxywon b888b2a8 [task:todo] Task B +Parent commit (@-): qpvuntsm 25687f26 Base commit + +# before +@ kpqxywon [task:todo] Task B +│ ○ zsuskuln [task:todo] Task A +├─╯ +○ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask wip zs +Rebased 1 commits to destination +Working copy (@) now at: kpqxywon d2eceaa9 [task:todo] Task B +Parent commit (@-) : qpvuntsm 25687f26 Base commit +Parent commit (@-) : zsuskuln 71a32632 [task:wip] Task A +Added 1 files, modified 0 files, removed 0 files + +# after +@ kpqxywon [task:todo] Task B +├─╮ +│ ○ zsuskuln [task:wip] Task A +├─╯ +○ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask wip k +Working copy (@) now at: kpqxywon 9c642b41 [task:wip] Task B +Parent commit (@-) : qpvuntsm 25687f26 Base commit +Parent commit (@-) : zsuskuln 71a32632 [task:wip] Task A + +# after +@ kpqxywon [task:wip] Task B +├─╮ +│ ○ zsuskuln [task:wip] Task A +├─╯ +○ qpvuntsm Base commit +◆ zzzzzzzz root() 00000000 + +$ jjtask squash +Working copy (@) now at: kpqxywon 4ab8214a Squashed tasks: +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) +Squashed 2 tasks into linear commit + +# after +@ kpqxywon Squashed tasks: +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/squash_single_parent.txt b/test/snapshots_go/squash_single_parent.txt new file mode 100644 index 0000000..d4afea4 --- /dev/null +++ b/test/snapshots_go/squash_single_parent.txt @@ -0,0 +1,32 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Single task +Created new commit kkmpptxz 3f1c5e81 (empty) [task:todo] Single task +Created new commit k (empty) [task:todo] Single task + +# after +○ kkmpptxz [task:todo] Single task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask wip k +Rebased 1 commits to destination +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm 263ce74e (empty) (no description set) +Parent commit (@-) : kkmpptxz 8ca0c0b6 (empty) [task:wip] Single task + +# after +@ qpvuntsm +○ kkmpptxz [task:wip] Single task +◆ zzzzzzzz root() 00000000 + +$ jjtask squash +Only one parent, nothing to merge-squash + +# after +@ qpvuntsm +○ kkmpptxz [task:wip] Single task +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/wip_multiple_revs_at_once.txt b/test/snapshots_go/wip_multiple_revs_at_once.txt new file mode 100644 index 0000000..8258263 --- /dev/null +++ b/test/snapshots_go/wip_multiple_revs_at_once.txt @@ -0,0 +1,68 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +# before +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task A +Created new commit mzvwutvl 7bcc46b5 (empty) [task:todo] Task A +Created new commit m (empty) [task:todo] Task A + +# after +○ mzvwutvl [task:todo] Task A +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task B +Created new commit yostqsxw f0a1d0a8 (empty) [task:todo] Task B +Created new commit y (empty) [task:todo] Task B + +# after +○ yostqsxw [task:todo] Task B +│ ○ mzvwutvl [task:todo] Task A +├─╯ +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task C +Created new commit wqnwkozp 3ecfbba1 (empty) [task:todo] Task C +Created new commit w (empty) [task:todo] Task C + +# after +○ wqnwkozp [task:todo] Task C +│ ○ yostqsxw [task:todo] Task B +├─╯ +│ ○ mzvwutvl [task:todo] Task A +├─╯ +@ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip m y w +Rebased 1 commits to destination +Rebased 3 descendant commits +Working copy (@) now at: qpvuntsm c2b80b78 Base +Parent commit (@-) : mzvwutvl 8cbfb6a0 (empty) [task:wip] Task A +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 84f6b467 Base +Parent commit (@-) : mzvwutvl 8cbfb6a0 (empty) [task:wip] Task A +Parent commit (@-) : yostqsxw 32b7e3b7 (empty) [task:wip] Task B +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm cc5865a9 Base +Parent commit (@-) : yostqsxw 32b7e3b7 (empty) [task:wip] Task B +Parent commit (@-) : mzvwutvl 8cbfb6a0 (empty) [task:wip] Task A +Parent commit (@-) : wqnwkozp 9b2a879e (empty) [task:wip] Task C + +# after +@ qpvuntsm Base +├─┬─╮ +│ │ ○ wqnwkozp [task:wip] Task C +│ ○ │ mzvwutvl [task:wip] Task A +│ ├─╯ +○ │ yostqsxw [task:wip] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/wip_multiple_tasks.txt b/test/snapshots_go/wip_multiple_tasks.txt new file mode 100644 index 0000000..b9d4ab4 --- /dev/null +++ b/test/snapshots_go/wip_multiple_tasks.txt @@ -0,0 +1,51 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task A +Created new commit kkmpptxz daaa8cef (empty) [task:todo] Task A +Created new commit k (empty) [task:todo] Task A + +# after +○ kkmpptxz [task:todo] Task A +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent root() Task B +Created new commit yqosqzyt 290e22cf (empty) [task:todo] Task B +Created new commit y (empty) [task:todo] Task B + +# after +○ kkmpptxz [task:todo] Task A +@ qpvuntsm +│ ○ yqosqzyt [task:todo] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask wip k +Rebased 1 commits to destination +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm 4e58855f (empty) (no description set) +Parent commit (@-) : kkmpptxz 353aaa62 (empty) [task:wip] Task A + +# after +@ qpvuntsm +○ kkmpptxz [task:wip] Task A +│ ○ yqosqzyt [task:todo] Task B +├─╯ +◆ zzzzzzzz root() 00000000 + +$ jjtask wip y +Rebased 1 commits to destination +Working copy (@) now at: qpvuntsm 06f2d53c (empty) (no description set) +Parent commit (@-) : kkmpptxz 353aaa62 (empty) [task:wip] Task A +Parent commit (@-) : yqosqzyt cd7e51c3 (empty) [task:wip] Task B + +# after +@ qpvuntsm +├─╮ +│ ○ yqosqzyt [task:wip] Task B +○ │ kkmpptxz [task:wip] Task A +├─╯ +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/wip_preserves_at_content.txt b/test/snapshots_go/wip_preserves_at_content.txt new file mode 100644 index 0000000..769e50b --- /dev/null +++ b/test/snapshots_go/wip_preserves_at_content.txt @@ -0,0 +1,48 @@ +# wrote base.txt (4 bytes) + +$ jj describe -m Base +Working copy (@) now at: qpvuntsm 7dea31c7 Base +Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + +$ jj new -m Working commit +Working copy (@) now at: kkmpptxz 367a79cf (empty) Working commit +Parent commit (@-) : qpvuntsm 7dea31c7 Base + +# wrote work.txt (7 bytes) + +$ jj status +Working copy changes: +A work.txt +Working copy (@) : kkmpptxz 91e440bd Working commit +Parent commit (@-): qpvuntsm 7dea31c7 Base + +# before +@ kkmpptxz Working commit +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask create --parent q Task +Created new commit yqosqzyt 2b713f66 (empty) [task:todo] Task +Created new commit y (empty) [task:todo] Task + +# after +@ kkmpptxz Working commit +│ ○ yqosqzyt [task:todo] Task +├─╯ +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + +$ jjtask wip y +Rebased 1 commits to destination +Working copy (@) now at: kkmpptxz d9329cbd Working commit +Parent commit (@-) : qpvuntsm 7dea31c7 Base +Parent commit (@-) : yqosqzyt 78b187f6 (empty) [task:wip] Task + +# after +@ kkmpptxz Working commit +├─╮ +│ ○ yqosqzyt [task:wip] Task +├─╯ +○ qpvuntsm Base +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/wip_single_task.txt b/test/snapshots_go/wip_single_task.txt new file mode 100644 index 0000000..3c97c2c --- /dev/null +++ b/test/snapshots_go/wip_single_task.txt @@ -0,0 +1,35 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Single task +Created new commit kkmpptxz 3f1c5e81 (empty) [task:todo] Single task +Created new commit k (empty) [task:todo] Single task + +# after +○ kkmpptxz [task:todo] Single task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask wip k +Rebased 1 commits to destination +Rebased 1 descendant commits +Working copy (@) now at: qpvuntsm 263ce74e (empty) (no description set) +Parent commit (@-) : kkmpptxz 8ca0c0b6 (empty) [task:wip] Single task + +# after +@ qpvuntsm +○ kkmpptxz [task:wip] Single task +◆ zzzzzzzz root() 00000000 + +$ jjtask find +@ qpvuntsm +○ kkmpptxz [task:wip] Single task +│ +~ + +# after +@ qpvuntsm +○ kkmpptxz [task:wip] Single task +◆ zzzzzzzz root() 00000000 + diff --git a/test/snapshots_go/wip_when_at_is_task.txt b/test/snapshots_go/wip_when_at_is_task.txt new file mode 100644 index 0000000..07b4d43 --- /dev/null +++ b/test/snapshots_go/wip_when_at_is_task.txt @@ -0,0 +1,31 @@ +# before +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask create Task +Created new commit kkmpptxz 7ec0743e (empty) [task:todo] Task +Created new commit k (empty) [task:todo] Task + +# after +○ kkmpptxz [task:todo] Task +@ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jj edit k +Working copy (@) now at: kkmpptxz 7ec0743e (empty) [task:todo] Task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# before +@ kkmpptxz [task:todo] Task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + +$ jjtask wip +Working copy (@) now at: kkmpptxz ec893069 (empty) [task:wip] Task +Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set) + +# after +@ kkmpptxz [task:wip] Task +○ qpvuntsm +◆ zzzzzzzz root() 00000000 + diff --git a/test/test_helper.bash b/test/test_helper.bash deleted file mode 100755 index 4503b24..0000000 --- a/test/test_helper.bash +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env bash -# Test helper functions for jjtask tests - -JJTASK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -export JJTASK_ROOT - -# Set up test environment -export PATH="$JJTASK_ROOT/bin:$PATH" -export JJ_CONFIG="$JJTASK_ROOT/config/conf.d" - -# Create isolated test repo -setup_test_repo() { - TEST_REPO=$(mktemp -d) - export TEST_REPO - cd "$TEST_REPO" || exit 1 - - # Initialize jj repo - jj git init --colocate >/dev/null 2>&1 - - # Configure for tests - use global to avoid warnings - export JJ_USER="Test User" - export JJ_EMAIL="test@example.com" -} - -# Clean up test repo -teardown_test_repo() { - if [[ -n "$TEST_REPO" && -d "$TEST_REPO" ]]; then - rm -rf "$TEST_REPO" - fi -} - -# Assert command succeeds -assert_success() { - if [[ $? -ne 0 ]]; then - echo "FAIL: Expected success but got failure" - return 1 - fi -} - -# Assert command fails -assert_failure() { - if [[ $? -eq 0 ]]; then - echo "FAIL: Expected failure but got success" - return 1 - fi -} - -# Assert output contains string -assert_output_contains() { - local expected="$1" - local actual="$2" - if [[ "$actual" != *"$expected"* ]]; then - echo "FAIL: Expected output to contain '$expected'" - echo "Actual: $actual" - return 1 - fi -} - -# Assert output equals string -assert_output_equals() { - local expected="$1" - local actual="$2" - if [[ "$actual" != "$expected" ]]; then - echo "FAIL: Expected '$expected'" - echo "Actual: '$actual'" - return 1 - fi -} - -# Check if a task with given flag exists in description -# Uses all() since tasks created with --no-edit aren't in @'s ancestry -has_task_with_flag() { - local flag="$1" - jj log -r "all()" --no-graph -T 'description' 2>/dev/null | grep -q "\[task:$flag\]" -} - -# Get first task ID with given flag -# Parse output to avoid wrapper blocking on [task: in template -get_task_id() { - local flag="$1" - # Get all revisions and grep for the flag, then extract ID - jj log -r "all()" --no-graph -T 'change_id.shortest(8) ++ " " ++ description.first_line() ++ "\n"' 2>/dev/null | \ - grep "\[task:$flag\]" | head -1 | cut -d' ' -f1 -} - -# Create multi-repo test environment -# Structure: root/ with frontend/ and backend/ jj repos -setup_multi_repo() { - TEST_REPO=$(mktemp -d) - export TEST_REPO - export WORKSPACE_ROOT="$TEST_REPO" - cd "$TEST_REPO" || exit 1 - - # Create nested repos - mkdir -p frontend backend - - jj git init --colocate >/dev/null 2>&1 - (cd frontend && jj git init --colocate >/dev/null 2>&1) - (cd backend && jj git init --colocate >/dev/null 2>&1) - - # Ignore workspace config in root repo (commit it so it doesn't show in diff) - echo ".jj-workspaces.yaml" > .gitignore - jj commit -m "Add .gitignore" >/dev/null 2>&1 - - # Create workspace config - cat > .jj-workspaces.yaml <<EOF -repos: - - path: . - name: root - - path: frontend - name: frontend - - path: backend - name: backend -EOF - - export JJ_USER="Test User" - export JJ_EMAIL="test@example.com" -} - -# Clean up multi-repo test -teardown_multi_repo() { - teardown_test_repo -} - -# Snapshot testing support -SNAPSHOTS_DIR="$JJTASK_ROOT/test/snapshots" - -# Normalize output for snapshot comparison -# Replaces variable parts with stable placeholders -normalize_output() { - sed -E \ - -e 's/tmp\.[A-Za-z0-9]+/TMPDIR/g' \ - -e 's/cwd: [^ ]+ \|/cwd: TMPDIR |/g' \ - -e 's/repo: [^ ]+ \|/repo: TMPDIR |/g' \ - -e 's/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/TIMESTAMP/g' \ - -e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([+-][0-9]{2}:[0-9]{2}|Z)/TIMESTAMP/g' \ - -e 's|/tmp/[^/ ]+|/tmp/TMPDIR|g' \ - -e 's|/var/folders/[^/]+/[^/]+/[^/]+|/var/folders/TMPDIR|g' \ - -e 's/^(@|○|│) [a-z]{1,8} /\1 ID /g' \ - -e 's/^(@|○|│) [a-z]{8} *$/\1 ID/g' \ - -e 's/│ (○) [a-z]{1,8} /│ \1 ID /g' \ - -e 's/^[a-z]{1,12} (\(empty\)|Test|\[task|\[todo|\[draft|\[wip|\[done|\[blocked)/ID \1/g' \ - -e 's/^([a-z]{1,12}) \(([a-z]{1,8})\)/ID (ID)/g' \ - -e 's/(ID) \(([a-z]{1,8})\)/ID (ID)/g' \ - -e 's/│ ○ (ID) \(([a-z]{1,8})\)/│ ○ ID (ID)/g' \ - -e 's/TIMESTAMP [a-z0-9]{1,8}$/TIMESTAMP ID/g' \ - -e 's/operation: [a-f0-9]{12}/operation: OPID/g' \ - -e 's/op restore [a-f0-9]{12}/op restore OPID/g' \ - -e 's/to hoist: [a-z ]+$/to hoist: ID/g' \ - -e 's/Rebasing [a-z]{1,8} /Rebasing ID /g' \ - -e 's/^ [a-z]{1,8} already/ ID already/g' \ - -e 's/Working copy .* at: [a-z]+ [a-f0-9]+/Working copy at: ID HASH/g' \ - -e 's/Parent commit .* : [a-z]+ [a-f0-9]+/Parent commit: ID HASH/g' \ - -e 's/Started: [0-9]+[hmd] ago/Started: TIME ago/g' \ - -e 's/started: TIMESTAMP/started: TIMESTAMP/g' \ - -e 's/Parent: [a-z]+ /Parent: ID /g' \ - -e 's/← [a-z]{1,8} \[task/← ID [task/g' \ - -e 's/Task: [a-z]{1,8}$/Task: ID/g' \ - -e 's/Session: [a-z]{1,12} \[task/Session: ID [task/g' \ - -e 's/Created new commit [a-z]+ [a-f0-9]+/Created new commit ID HASH/g' \ - -e 's/Rebased [0-9]+ descendant/Rebased N descendant/g' -} - -# Assert output matches snapshot -# Usage: assert_snapshot "test_name" "$output" -# Set SNAPSHOT_UPDATE=1 to regenerate snapshots -assert_snapshot() { - local name="$1" - local actual="$2" - local snapshot_file="$SNAPSHOTS_DIR/${name}.txt" - local normalized - normalized=$(echo "$actual" | normalize_output) - - if [[ "${SNAPSHOT_UPDATE:-}" == "1" ]]; then - mkdir -p "$SNAPSHOTS_DIR" - echo "$normalized" > "$snapshot_file" - echo " Updated snapshot: $name" - return 0 - fi - - if [[ ! -f "$snapshot_file" ]]; then - echo "FAIL: Snapshot not found: $snapshot_file" - echo "Run with SNAPSHOT_UPDATE=1 to create" - echo "Actual (normalized):" - echo "$normalized" - return 1 - fi - - local expected - expected=$(cat "$snapshot_file") - - if [[ "$normalized" != "$expected" ]]; then - echo "FAIL: Snapshot mismatch for $name" - echo "--- Expected ---" - echo "$expected" - echo "--- Actual ---" - echo "$normalized" - echo "--- Diff ---" - diff <(echo "$expected") <(echo "$normalized") || true - return 1 - fi -}