App-karr

 view release on metacpan or  search on metacpan

docs/superpowers/plans/2026-03-19-v0004-implementation.md  view on Meta::CPAN


| File | Responsibility |
|------|---------------|
| `lib/App/karr/Cmd/Log.pm` | Activity log command |
| `t/13-git-refs.t` | Git ref roundtrip tests (commit-wrapped write/read) |
| `t/14-task-parse.t` | `from_string`/`from_file` parity tests |
| `t/15-sync.t` | Materialize/serialize roundtrip tests |
| `t/16-pick-lock.t` | Pick with Lock integration test |
| `t/17-log.t` | Log append + read tests |
| `t/18-list-filter.t` | `--claimed-by` filter test |
| `t/19-git-push-fetch.t` | Push/fetch between two repos test |
| `t/20-next-id-collision.t` | Config next_id collision prevention test |

---

## Chunk 1: Git.pm Foundation

All other changes depend on Git.pm having safe execution and commit-wrapped refs.

### Task 1: Rewrite Git.pm with safe execution

**Files:**
- Modify: `lib/App/karr/Git.pm`
- Test: `t/13-git-refs.t`

- [ ] **Step 1: Write failing test for `_git_cmd`**

```perl
# t/13-git-refs.t
use strict;
use warnings;
use Test::More;
use File::Temp qw( tempdir );
use App::karr::Git;

# Create a temporary git repo
my $dir = tempdir( CLEANUP => 1 );
system("git init '$dir' 2>/dev/null");
system("git -C '$dir' config user.email 'test\@test.com'");
system("git -C '$dir' config user.name 'Test'");

my $git = App::karr::Git->new( dir => $dir );

# Test is_repo
ok $git->is_repo, 'detects git repo';

# Test is_repo from subdirectory
my $subdir = "$dir/karr";
mkdir $subdir;
my $sub_git = App::karr::Git->new( dir => $subdir );
ok $sub_git->is_repo, 'detects git repo from subdirectory';

# Test non-repo
my $non_repo = tempdir( CLEANUP => 1 );
my $non_git = App::karr::Git->new( dir => $non_repo );
ok !$non_git->is_repo, 'non-repo returns false';

done_testing;
```

- [ ] **Step 2: Run test to verify it fails**

Run: `prove -l t/13-git-refs.t`
Expected: FAIL (is_repo uses old `.git` child check, subdirectory fails)

- [ ] **Step 3: Implement `_git_cmd`, `_git_cmd_stdin`, and fixed `is_repo`**

Replace the entire `Git.pm` with safe execution:

```perl
# ABSTRACT: Git operations for karr sync (via CLI)

package App::karr::Git;

use strict;
use warnings;
use Path::Tiny qw( path );
use IPC::Open2;

sub new {
    my ( $class, %args ) = @_;
    return bless {
        dir => $args{dir} // '.',
    }, $class;
}

sub dir {
    my ($self) = @_;
    return path( $self->{dir} );
}

sub _git_cmd {
    my ($self, @cmd) = @_;
    my $dir = $self->dir->stringify;
    my $pid = open(my $fh, '-|');
    if (!defined $pid) {
        die "fork failed: $!";
    }
    if (!$pid) {
        open(STDERR, '>', '/dev/null');
        chdir $dir or die "chdir $dir: $!";
        exec('git', @cmd) or die "exec git: $!";
    }
    my $output = do { local $/; <$fh> };
    close $fh;
    my $ok = $? == 0;
    chomp $output if defined $output;
    return wantarray ? ($output, $ok) : $output;
}

sub _git_cmd_stdin {
    my ($self, $input, @cmd) = @_;
    my $dir = $self->dir->stringify;
    my $pid = open2(my $out_fh, my $in_fh, 'git', '-C', $dir, @cmd);
    print $in_fh $input;
    close $in_fh;
    my $output = do { local $/; <$out_fh> };
    waitpid($pid, 0);
    chomp $output if defined $output;
    return $output;
}

docs/superpowers/plans/2026-03-19-v0004-implementation.md  view on Meta::CPAN

    return $ok ? $content : '';
}

sub delete_ref {
    my ( $self, $ref ) = @_;
    $self->_git_cmd('update-ref', '-d', $ref);
    return 1;
}

sub fetch {
    my ( $self, $remote ) = @_;
    $remote //= 'origin';
    my (undef, $ok) = $self->_git_cmd('fetch', $remote);
    return $ok;
}

sub push {
    my ( $self, $remote, $refspec ) = @_;
    $remote //= 'origin';
    $refspec //= 'refs/karr/*:refs/karr/*';
    my (undef, $ok) = $self->_git_cmd('push', $remote, $refspec);
    return $ok;
}

sub pull {
    my ( $self, $remote ) = @_;
    $remote //= 'origin';
    my (undef, $ok) = $self->_git_cmd('fetch', $remote, 'refs/karr/*:refs/karr/*');
    return $ok;
}

sub save_task_ref {
    my ($self, $task) = @_;
    my $ref = "refs/karr/tasks/" . $task->id . "/data";
    $self->write_ref($ref, $task->to_markdown);
}

sub load_task_ref {
    my ($self, $id) = @_;
    my $ref = "refs/karr/tasks/$id/data";
    my $content = $self->read_ref($ref);
    return undef unless $content;
    require App::karr::Task;
    return App::karr::Task->from_string($content);
}

sub list_task_refs {
    my ($self) = @_;
    my $output = $self->_git_cmd('for-each-ref', '--format=%(refname)', 'refs/karr/tasks/');
    return () unless $output;
    my %ids;
    for (split /\n/, $output) {
        $ids{$1} = 1 if m{refs/karr/tasks/(\d+)/};
    }
    return sort { $a <=> $b } keys %ids;
}

1;
```

- [ ] **Step 4: Run test to verify it passes**

Run: `prove -l t/13-git-refs.t`
Expected: PASS

- [ ] **Step 5: Extend test for commit-wrapped write_ref/read_ref roundtrip**

Append to `t/13-git-refs.t`:

```perl
# Test write_ref / read_ref roundtrip with commit-wrapped refs
my $test_content = "---\nid: 1\ntitle: Test task\n---\n\nBody here.\n";
ok $git->write_ref('refs/karr/test/data', $test_content), 'write_ref succeeds';

my $read_back = $git->read_ref('refs/karr/test/data');
is $read_back, $test_content, 'read_ref returns original content';

# Verify the ref points to a commit (not a blob)
my $obj_type = $git->_git_cmd('cat-file', '-t', 'refs/karr/test/data');
is $obj_type, 'commit', 'ref points to a commit object';

# Test read of nonexistent ref
my $missing = $git->read_ref('refs/karr/nonexistent');
is $missing, '', 'missing ref returns empty string';

# Test delete_ref
$git->delete_ref('refs/karr/test/data');
my $after_delete = $git->read_ref('refs/karr/test/data');
is $after_delete, '', 'deleted ref returns empty string';
```

- [ ] **Step 6: Run test to verify it passes**

Run: `prove -l t/13-git-refs.t`
Expected: PASS

- [ ] **Step 7: Run all existing tests to verify no regressions**

Run: `prove -l t/`
Expected: All pass (Git.pm interface is backward-compatible for existing callers)

- [ ] **Step 8: Commit**

```bash
git add lib/App/karr/Git.pm t/13-git-refs.t
git commit -m "feat: rewrite Git.pm with safe execution and commit-wrapped refs"
```

---

### Task 2: Refactor Task.pm parsing

**Files:**
- Modify: `lib/App/karr/Task.pm`
- Test: `t/14-task-parse.t`

- [ ] **Step 1: Write failing test for `from_string`**

```perl
# t/14-task-parse.t
use strict;
use warnings;
use Test::More;
use File::Temp qw( tempdir );
use Path::Tiny;
use App::karr::Task;

my $content = <<'MD';
---
id: 42
title: Test from_string
status: todo
priority: high
class: standard
created: 2026-03-19T10:00:00Z
updated: 2026-03-19T10:00:00Z
---

This is the body.
MD

# Test from_string
my $task = App::karr::Task->from_string($content);
is $task->id, 42, 'from_string: id';
is $task->title, 'Test from_string', 'from_string: title';
is $task->status, 'todo', 'from_string: status';
is $task->body, 'This is the body.', 'from_string: body';
ok !$task->has_file_path, 'from_string: no file_path';

# Test from_file gives same result
my $dir = tempdir( CLEANUP => 1 );
my $file = path($dir)->child('042-test-from-string.md');
$file->spew_utf8($content);

my $file_task = App::karr::Task->from_file($file);
is $file_task->id, $task->id, 'from_file matches from_string: id';
is $file_task->title, $task->title, 'from_file matches from_string: title';
is $file_task->body, $task->body, 'from_file matches from_string: body';
ok $file_task->has_file_path, 'from_file: has file_path';

done_testing;
```

- [ ] **Step 2: Run test to verify it fails**

Run: `prove -l t/14-task-parse.t`
Expected: FAIL (`from_string` method does not exist yet)

- [ ] **Step 3: Implement `_parse_content` and `from_string`, refactor `from_file`**

In `lib/App/karr/Task.pm`, replace the existing `from_file` with:

```perl
sub _parse_content {
    my ($class, $content) = @_;
    my ($yaml, $body) = $content =~ m{^---\n(.+?)---\n(.*)$}s
        or die "Invalid task format\n";
    $body //= '';
    $body =~ s/^\n//;
    $body =~ s/\n$//;
    return (Load($yaml), $body);
}

sub from_string {
    my ($class, $content) = @_;
    my ($fm, $body) = $class->_parse_content($content);
    return $class->new(%$fm, body => $body);
}

sub from_file {
    my ($class, $file) = @_;
    $file = path($file);
    my ($fm, $body) = $class->_parse_content($file->slurp_utf8);
    return $class->new(%$fm, body => $body, file_path => $file);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `prove -l t/14-task-parse.t`
Expected: PASS

- [ ] **Step 5: Run all existing tests for regressions**

Run: `prove -l t/`
Expected: All pass (from_file behavior unchanged)

- [ ] **Step 6: Commit**

```bash
git add lib/App/karr/Task.pm t/14-task-parse.t
git commit -m "refactor: extract _parse_content, add from_string to Task"
```

---

### Task 3: Complete Git.pm task ref tests

**Files:**
- Modify: `t/13-git-refs.t`

Now that Task.pm has `from_string`, complete the task ref roundtrip tests.

- [ ] **Step 1: Add save/load/list task ref tests**

Append to `t/13-git-refs.t`:

```perl
use App::karr::Task;

# Test save_task_ref / load_task_ref roundtrip
my $task = App::karr::Task->new(
    id       => 1,
    title    => 'Test save ref',
    status   => 'todo',
    priority => 'high',
    class    => 'standard',
    body     => 'Some body text',
);

$git->save_task_ref($task);

my $loaded = $git->load_task_ref(1);
ok $loaded, 'load_task_ref returns task';
is $loaded->id, 1, 'loaded task id';
is $loaded->title, 'Test save ref', 'loaded task title';
is $loaded->body, 'Some body text', 'loaded task body';

# Test list_task_refs
my $task2 = App::karr::Task->new(
    id => 2, title => 'Second task', status => 'backlog',
    priority => 'medium', class => 'standard',
);
$git->save_task_ref($task2);

my @ids = $git->list_task_refs;
is_deeply \@ids, [1, 2], 'list_task_refs returns sorted IDs';

docs/superpowers/plans/2026-03-19-v0004-implementation.md  view on Meta::CPAN

    board => { name => 'Test Board' },
    tasks_dir => 'tasks',
    statuses => ['backlog', 'todo', 'in-progress', 'review', 'done', 'archived'],
    priorities => ['low', 'medium', 'high', 'critical'],
    next_id => 3,
    defaults => { status => 'backlog', priority => 'medium', class => 'standard' },
};
DumpFile($board->child('config.yml')->stringify, $config);

# Create two task files locally
my $t1 = App::karr::Task->new(
    id => 1, title => 'Local task one', status => 'todo',
    priority => 'high', class => 'standard', body => 'Body one',
);
$t1->save($board->child('tasks'));

my $t2 = App::karr::Task->new(
    id => 2, title => 'Local task two', status => 'backlog',
    priority => 'medium', class => 'standard',
);
$t2->save($board->child('tasks'));

# Serialize local files to refs
my $git = App::karr::Git->new( dir => $repo );
for my $file ($board->child('tasks')->children(qr/\.md$/)) {
    my $task = App::karr::Task->from_file($file);
    $git->save_task_ref($task);
}
$git->write_ref('refs/karr/config', $board->child('config.yml')->slurp_utf8);

# Verify refs exist
my @ids = $git->list_task_refs;
is_deeply \@ids, [1, 2], 'serialize: both tasks in refs';

# Delete local files to simulate fresh materialization
for my $f ($board->child('tasks')->children(qr/\.md$/)) {
    $f->remove;
}
my @remaining = $board->child('tasks')->children(qr/\.md$/);
is scalar @remaining, 0, 'local files cleared';

# Materialize from refs
for my $id (@ids) {
    my $task = $git->load_task_ref($id);
    $task->save($board->child('tasks'));
}

# Verify files recreated
my @files = sort $board->child('tasks')->children(qr/\.md$/);
is scalar @files, 2, 'materialize: two files recreated';

# Verify content matches
my $reloaded = App::karr::Task->from_file($files[0]);
is $reloaded->id, 1, 'materialized task 1: id';
is $reloaded->title, 'Local task one', 'materialized task 1: title';
is $reloaded->body, 'Body one', 'materialized task 1: body';

done_testing;
```

- [ ] **Step 2: Run test to verify it passes (uses already-implemented Git methods)**

Run: `prove -l t/15-sync.t`
Expected: PASS (this tests the pattern, not the role methods yet)

- [ ] **Step 3: Implement sync helpers in BoardAccess role**

Add to `lib/App/karr/Role/BoardAccess.pm`:

```perl
sub sync_before {
    my ($self) = @_;
    require App::karr::Git;
    my $git = App::karr::Git->new(dir => $self->board_dir->parent->stringify);
    return unless $git->is_repo;
    $git->pull;
    $self->_materialize_from_refs($git);
}

sub sync_after {
    my ($self) = @_;
    require App::karr::Git;
    my $git = App::karr::Git->new(dir => $self->board_dir->parent->stringify);
    return unless $git->is_repo;
    $self->_serialize_to_refs($git);
    $git->push;
}

sub _materialize_from_refs {
    my ($self, $git) = @_;
    my @ids = $git->list_task_refs;
    my $tasks_dir = $self->tasks_dir;
    $tasks_dir->mkpath;

    # First: serialize any locally-created tasks (no ref yet) to refs
    if ($tasks_dir->exists) {
        for my $file ($tasks_dir->children(qr/\.md$/)) {
            require App::karr::Task;
            my $task = App::karr::Task->from_file($file);
            my $ref_content = $git->read_ref("refs/karr/tasks/" . $task->id . "/data");
            unless ($ref_content) {
                $git->save_task_ref($task);
                push @ids, $task->id unless grep { $_ == $task->id } @ids;
            }
        }

        # Clear all .md files to avoid stale entries
        for my $old_file ($tasks_dir->children(qr/\.md$/)) {
            $old_file->remove;
        }
    }

    # Materialize from refs
    for my $id (@ids) {
        my $task = $git->load_task_ref($id);
        next unless $task;
        $task->save($tasks_dir);
    }

    # Materialize config
    my $config_content = $git->read_ref('refs/karr/config');



( run in 0.506 second using v1.01-cache-2.11-cpan-e1769b4cff6 )