<?php
namespace App\Commands;
use App\Commands\Concerns\InteractsWithPijul;
use App\Prompts\LiveSuggestPrompt;
use Laravel\Prompts\MultiSelectPrompt;
use LaravelZero\Framework\Commands\Command;
use RuntimeException;
class PijulRecordWithIntentTagsCommand extends Command
{
use InteractsWithPijul;
private const PROMPT_SCROLL = 10;
protected $signature = 'pijul:record-with-intent-tags
{prefixes?* : Paths in which to record the changes}
{--a|all : Record all paths that have changed}
{--m|message= : Set the change message}
{--description= : Set the description field}
{--author= : Set the author field}
{--timestamp= : Set the timestamp field}
{--ignore-missing : Ignore missing (deleted) files}
{--working-copy= : Override the working copy path}
{--amend= : Amend this change instead of creating a new change}
{--identity= : Identity to sign changes with}
{--patience : Use Patience diff}
{--histogram : Use Histogram diff}
{--repository= : Work with the repository at PATH}
{--channel= : Work with CHANNEL instead of the current channel}
{--no-prompt : Abort rather than prompt for input}
{--list-tags : Print known intent tags and exit}
{--tag-limit=500 : Number of recent log entries to scan}';
protected $description = 'Record a Pijul change with reusable intent tags';
protected $aliases = ['pijul-record-with-intent-tags'];
public function handle(PijulListIntentTagsCommand $listIntentTags): int
{
$tags = $listIntentTags->resolveTags(
limit: (string) $this->option('tag-limit'),
repository: $this->option('repository'),
channel: $this->option('channel'),
noPrompt: (bool) $this->option('no-prompt'),
);
if ($this->option('list-tags')) {
foreach ($tags as $tag) {
$this->line($tag);
}
return self::SUCCESS;
}
$message = $this->option('message');
if ($message !== null) {
$this->printKnownTags($tags);
$this->recordChange((string) $message);
return self::SUCCESS;
}
if (! $this->input->isInteractive()) {
$this->error('This command needs an interactive terminal unless you pass --message.');
return self::FAILURE;
}
$this->printKnownTags($tags);
$tag = $this->promptForTag($tags);
$body = (string) $this->ask('message');
$fullMessage = $this->buildMessage($tag, $body);
if ($fullMessage === '') {
$this->error('Change message cannot be empty.');
return self::FAILURE;
}
$this->recordChange($fullMessage);
return self::SUCCESS;
}
private function printKnownTags(array $tags): void
{
if ($tags === []) {
return;
}
$this->line(sprintf('Known tags: %s', implode(', ', $tags)));
}
private function buildMessage(string $tag, string $body): string
{
$tag = trim($tag);
$body = trim($body);
if ($tag !== '' && $body !== '') {
return sprintf('%s: %s', $tag, $body);
}
return $tag !== '' ? $tag : $body;
}
private function recordChange(string $message): void
{
if (! $this->input->isInteractive()) {
$this->runPijul($this->recordArguments($message));
return;
}
$hunks = $this->extractRecordHunks($this->captureRecordTemplate());
$selectedHunks = $this->promptForHunkSelection($hunks);
$editorScript = $this->createRecordEditorScript();
try {
$editorCommand = $this->recordEditorCommand($editorScript);
$this->runPijul(
$this->recordArguments(),
environment: [
'EDITOR' => $editorCommand,
'VISUAL' => $editorCommand,
'ANI_PIJUL_EDITOR_MODE' => 'apply',
'ANI_PIJUL_MESSAGE' => $message,
'ANI_PIJUL_SELECTED_HUNKS' => json_encode($selectedHunks, JSON_THROW_ON_ERROR),
],
disableTimeout: true,
);
} finally {
unlink($editorScript);
}
}
private function promptForTag(array $tags): string
{
if ($tags === []) {
return '';
}
return trim((string) (new LiveSuggestPrompt(
label: 'Intent tag (optional)',
options: fn (string $value): array => $this->suggestedTags($tags, $value),
placeholder: 'Type to filter known tags, or press Enter to skip',
scroll: self::PROMPT_SCROLL,
hint: 'Results update as you type. Use arrows to choose, or press Enter on an empty prompt to skip.',
))->prompt());
}
private function captureRecordTemplate(): string
{
$editorScript = $this->createRecordEditorScript();
$capturePath = tempnam(sys_get_temp_dir(), 'ani-pijul-capture-');
if ($capturePath === false) {
unlink($editorScript);
throw new RuntimeException('Unable to create a temporary file for capturing Pijul hunks.');
}
try {
$result = $this->tryPijul(
$this->recordArguments(),
environment: [
'EDITOR' => $this->recordEditorCommand($editorScript),
'VISUAL' => $this->recordEditorCommand($editorScript),
'ANI_PIJUL_EDITOR_MODE' => 'capture',
'ANI_PIJUL_CAPTURE_PATH' => $capturePath,
],
disableTimeout: true,
);
$template = file_get_contents($capturePath);
if (is_string($template) && $template !== '') {
return $template;
}
$this->ensurePijulSucceeded($result);
} finally {
unlink($editorScript);
if (is_file($capturePath)) {
unlink($capturePath);
}
}
throw new RuntimeException('Unable to capture Pijul hunks.');
}
private function extractRecordHunks(string $template): array
{
preg_match_all('/(?ms)^(?<number>\d+)\.\s.*?(?=^\d+\.\s|\z)/', $template, $matches, PREG_SET_ORDER);
return array_map(function (array $match): array {
$lines = preg_split('/\R/', trim($match[0])) ?: [];
$headline = trim((string) array_shift($lines));
$detail = '';
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, 'up ')) {
continue;
}
$detail = $line;
break;
}
return [
'number' => (int) $match['number'],
'label' => $detail === ''
? $headline
: sprintf('%s - %s', $headline, $detail),
];
}, $matches);
}
private function promptForHunkSelection(array $hunks): array
{
if ($hunks === []) {
throw new RuntimeException('No Pijul hunks were found to record.');
}
$options = [];
foreach ($hunks as $hunk) {
$options[(string) $hunk['number']] = $hunk['label'];
}
return array_map(
static fn (string $value): int => (int) $value,
(new MultiSelectPrompt(
label: 'Select hunks to record',
options: $options,
default: count($options) === 1 ? array_keys($options) : [],
scroll: self::PROMPT_SCROLL,
required: 'Select at least one hunk to record.',
hint: 'Use space to toggle hunks, Ctrl+A to toggle all, and Enter to continue.',
))->prompt(),
);
}
private function suggestedTags(array $tags, string $query): array
{
$query = trim($query);
if ($query === '') {
return array_values($tags);
}
return array_values(array_filter(
$tags,
fn (string $tag): bool => str_contains(strtolower($tag), strtolower($query)),
));
}
private function createRecordEditorScript(): string
{
$path = tempnam(sys_get_temp_dir(), 'ani-pijul-editor-');
if ($path === false) {
throw new RuntimeException('Unable to create a temporary Pijul editor helper.');
}
$script = <<<'PHP'
<?php
$file = $argv[1] ?? null;
if (! is_string($file) || $file === '') {
fwrite(STDERR, "Missing Pijul editor file.\n");
exit(1);
}
$mode = getenv('ANI_PIJUL_EDITOR_MODE');
$template = file_get_contents($file);
if ($template === false) {
fwrite(STDERR, "Unable to read the Pijul editor file.\n");
exit(1);
}
if ($mode === 'capture') {
$capturePath = getenv('ANI_PIJUL_CAPTURE_PATH');
if (! is_string($capturePath) || $capturePath === '') {
fwrite(STDERR, "Missing ANI_PIJUL_CAPTURE_PATH.\n");
exit(1);
}
if (file_put_contents($capturePath, $template) === false) {
fwrite(STDERR, "Unable to capture the Pijul editor file.\n");
exit(1);
}
fwrite(STDERR, "Captured Pijul hunks.\n");
exit(1);
}
if ($mode !== 'apply') {
fwrite(STDERR, "Unsupported ANI_PIJUL_EDITOR_MODE.\n");
exit(1);
}
$message = getenv('ANI_PIJUL_MESSAGE');
if (! is_string($message)) {
fwrite(STDERR, "Missing ANI_PIJUL_MESSAGE.\n");
exit(1);
}
$encodedMessage = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (! is_string($encodedMessage)) {
fwrite(STDERR, "Unable to encode the Pijul change message.\n");
exit(1);
}
$updated = preg_replace('/^message = .*$/m', 'message = '.$encodedMessage, $template, 1, $count);
if (! is_string($updated) || $count !== 1) {
fwrite(STDERR, "Unable to prefill the Pijul change message.\n");
exit(1);
}
$selectedHunks = json_decode((string) getenv('ANI_PIJUL_SELECTED_HUNKS'), true);
if (! is_array($selectedHunks)) {
fwrite(STDERR, "Missing ANI_PIJUL_SELECTED_HUNKS.\n");
exit(1);
}
$selected = [];
foreach ($selectedHunks as $value) {
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
$selected[(string) $value] = true;
}
}
if ($selected === []) {
fwrite(STDERR, "No hunks were selected.\n");
exit(1);
}
if (preg_match('/^\d+\.\s/m', $updated, $firstHunk, PREG_OFFSET_CAPTURE) !== 1) {
fwrite(STDERR, "Unable to find hunks in the Pijul editor file.\n");
exit(1);
}
$header = rtrim(substr($updated, 0, $firstHunk[0][1]), "\n");
$body = substr($updated, $firstHunk[0][1]);
if (preg_match_all('/(?ms)^\d+\.\s.*?(?=^\d+\.\s|\z)/', $body, $matches) !== false) {
$blocks = [];
foreach ($matches[0] as $block) {
if (preg_match('/^(?<number>\d+)\.\s/m', $block, $numberMatch) !== 1) {
continue;
}
if (isset($selected[$numberMatch['number']])) {
$blocks[] = rtrim($block, "\n");
}
}
if ($blocks === []) {
fwrite(STDERR, "None of the selected hunks matched the Pijul editor file.\n");
exit(1);
}
$updated = $header."\n\n".implode("\n\n", $blocks)."\n";
}
if (file_put_contents($file, $updated) === false) {
fwrite(STDERR, "Unable to update the Pijul editor file.\n");
exit(1);
}
PHP;
if (file_put_contents($path, $script) === false) {
unlink($path);
throw new RuntimeException('Unable to write the temporary Pijul editor helper.');
}
if (! chmod($path, 0700)) {
unlink($path);
throw new RuntimeException('Unable to make the temporary Pijul editor helper executable.');
}
return $path;
}
private function recordEditorCommand(string $script): string
{
return PHP_BINARY.' '.$script;
}
private function recordArguments(?string $message = null): array
{
$arguments = ['record'];
if ($message !== null) {
$arguments[] = '-m';
$arguments[] = $message;
}
if ($this->option('all')) {
$arguments[] = '--all';
}
foreach ([
'description',
'author',
'timestamp',
'working-copy',
'identity',
'repository',
'channel',
] as $option) {
$value = $this->option($option);
if ($value !== null) {
$arguments[] = sprintf('--%s', $option);
$arguments[] = (string) $value;
}
}
if ($this->input->hasParameterOption('--amend')) {
$arguments[] = '--amend';
if (($amend = $this->option('amend')) !== null) {
$arguments[] = (string) $amend;
}
}
foreach (['ignore-missing', 'patience', 'histogram', 'no-prompt'] as $option) {
if ($this->option($option)) {
$arguments[] = sprintf('--%s', $option);
}
}
foreach ($this->argument('prefixes') as $prefix) {
$arguments[] = (string) $prefix;
}
return $arguments;
}
}