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 )