Skip to content

Commit 445e4dd

Browse files
Lukáš Křížclaude
andcommitted
fix: git-compatible output for diff, show, merge, reset, log, status
- Diff: add index/---/+++/mode lines, produce hunks for added/deleted files, omit ',1' in hunk headers (git convention), strip trailing newline in splitLines - Show: resolve tag names, short hash prefixes, peel annotated tags, show commit diff - Merge: return MergeResult with fast-forward flag, show diffstat for ff merges - Reset: only print "HEAD is now at" for --hard mode - Log: use git-compatible date format (D M j H:i:s Y O) - Status: add footer messages for unstaged/untracked states - Add findByPrefix to ObjectStorageInterface and LooseObjectStorage - Fix AcceptanceTestCase to write files to disk before committing - Add 16 functional tests covering all output compatibility fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f90df6b commit 445e4dd

17 files changed

Lines changed: 762 additions & 70 deletions

phpunit.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
<testsuite name="E2E">
2121
<directory>tests/E2E</directory>
2222
</testsuite>
23+
<testsuite name="Functional">
24+
<directory>tests/Functional</directory>
25+
</testsuite>
2326
<testsuite name="Acceptance">
2427
<directory>tests/Acceptance</directory>
2528
</testsuite>

src/Application/Handler/DiffHandler.php

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ public function diffWorkingVsIndex(): array
3434
$fullPath = $this->repository->workDir . '/' . $path;
3535

3636
if (! file_exists($fullPath)) {
37-
$diffs[] = new FileDiff($path, FileStatus::Deleted, []);
37+
$diff = $this->diffDeletedFile($path, $entry->objectId);
38+
if ($diff instanceof FileDiff) {
39+
$diffs[] = $diff;
40+
}
41+
3842
continue;
3943
}
4044

@@ -52,8 +56,9 @@ public function diffWorkingVsIndex(): array
5256
$oldLines = $this->splitLines($indexBlob->content);
5357
$newLines = $this->splitLines($workingContent);
5458
$hunks = $this->diffAlgorithm->diff($oldLines, $newLines);
59+
$newBlob = new Blob($workingContent);
5560

56-
$diffs[] = new FileDiff($path, FileStatus::Modified, $hunks);
61+
$diffs[] = new FileDiff($path, FileStatus::Modified, $hunks, $entry->objectId, $newBlob->getId());
5762
}
5863

5964
return $diffs;
@@ -69,33 +74,27 @@ public function diffIndexVsHead(): array
6974
$diffs = [];
7075

7176
foreach ($index->getEntries() as $path => $entry) {
72-
if (! isset($headEntries[$path])) {
73-
$diffs[] = new FileDiff($path, FileStatus::Added, []);
74-
continue;
75-
}
76-
77-
if ($entry->objectId->equals($headEntries[$path])) {
78-
continue;
79-
}
80-
81-
$headBlob = $this->repository->objects->read($headEntries[$path]);
82-
$indexBlob = $this->repository->objects->read($entry->objectId);
83-
84-
if (! $headBlob instanceof Blob || ! $indexBlob instanceof Blob) {
85-
continue;
77+
$diff = $this->diffIndexEntryVsHead($path, $entry->objectId, $headEntries[$path] ?? null);
78+
if ($diff instanceof FileDiff) {
79+
$diffs[] = $diff;
8680
}
81+
}
8782

88-
$oldLines = $this->splitLines($headBlob->content);
89-
$newLines = $this->splitLines($indexBlob->content);
90-
$hunks = $this->diffAlgorithm->diff($oldLines, $newLines);
83+
return array_merge($diffs, $this->findDeletedFromHead($index, $headEntries));
84+
}
9185

92-
$diffs[] = new FileDiff($path, FileStatus::Modified, $hunks);
93-
}
86+
/**
87+
* @return list<FileDiff>
88+
*/
89+
public function diffRootCommit(ObjectId $commitId): array
90+
{
91+
$entries = $this->collectCommitEntries($commitId);
92+
$diffs = [];
9493

95-
// Deleted from HEAD
96-
foreach (array_keys($headEntries) as $path) {
97-
if (! $index->hasEntry($path)) {
98-
$diffs[] = new FileDiff($path, FileStatus::Deleted, []);
94+
foreach ($entries as $path => $objectId) {
95+
$diff = $this->diffAddedFile($path, $objectId);
96+
if ($diff instanceof FileDiff) {
97+
$diffs[] = $diff;
9998
}
10099
}
101100

@@ -124,6 +123,39 @@ public function diffCommits(ObjectId $oldCommitId, ObjectId $newCommitId): array
124123
return $diffs;
125124
}
126125

126+
/**
127+
* @param array<string, ObjectId> $headEntries
128+
* @return list<FileDiff>
129+
*/
130+
private function findDeletedFromHead(\Lukasojd\PureGit\Domain\Index\Index $index, array $headEntries): array
131+
{
132+
$diffs = [];
133+
134+
foreach ($headEntries as $path => $objectId) {
135+
if (! $index->hasEntry($path)) {
136+
$diff = $this->diffDeletedFile($path, $objectId);
137+
if ($diff instanceof FileDiff) {
138+
$diffs[] = $diff;
139+
}
140+
}
141+
}
142+
143+
return $diffs;
144+
}
145+
146+
private function diffIndexEntryVsHead(string $path, ObjectId $indexObjectId, ?ObjectId $headObjectId): ?FileDiff
147+
{
148+
if (! $headObjectId instanceof ObjectId) {
149+
return $this->diffAddedFile($path, $indexObjectId);
150+
}
151+
152+
if ($indexObjectId->equals($headObjectId)) {
153+
return null;
154+
}
155+
156+
return $this->diffModifiedFile($path, $headObjectId, $indexObjectId);
157+
}
158+
127159
private function diffPath(string $path, ?ObjectId $oldId, ?ObjectId $newId): ?FileDiff
128160
{
129161
if (! $oldId instanceof \Lukasojd\PureGit\Domain\Object\ObjectId && $newId instanceof ObjectId) {
@@ -150,7 +182,7 @@ private function diffAddedFile(string $path, ObjectId $newId): ?FileDiff
150182

151183
$hunks = $this->diffAlgorithm->diff([], $this->splitLines($blob->content));
152184

153-
return new FileDiff($path, FileStatus::Added, $hunks);
185+
return new FileDiff($path, FileStatus::Added, $hunks, newId: $newId);
154186
}
155187

156188
private function diffDeletedFile(string $path, ObjectId $oldId): ?FileDiff
@@ -162,7 +194,7 @@ private function diffDeletedFile(string $path, ObjectId $oldId): ?FileDiff
162194

163195
$hunks = $this->diffAlgorithm->diff($this->splitLines($blob->content), []);
164196

165-
return new FileDiff($path, FileStatus::Deleted, $hunks);
197+
return new FileDiff($path, FileStatus::Deleted, $hunks, oldId: $oldId);
166198
}
167199

168200
private function diffModifiedFile(string $path, ObjectId $oldId, ObjectId $newId): ?FileDiff
@@ -179,7 +211,7 @@ private function diffModifiedFile(string $path, ObjectId $oldId, ObjectId $newId
179211
$this->splitLines($newBlob->content),
180212
);
181213

182-
return new FileDiff($path, FileStatus::Modified, $hunks);
214+
return new FileDiff($path, FileStatus::Modified, $hunks, $oldId, $newId);
183215
}
184216

185217
/**
@@ -247,6 +279,11 @@ private function splitLines(string $content): array
247279
return [];
248280
}
249281

282+
// Remove trailing newline to avoid phantom empty line
283+
if (str_ends_with($content, "\n")) {
284+
$content = substr($content, 0, -1);
285+
}
286+
250287
return explode("\n", $content);
251288
}
252289
}

src/Application/Handler/MergeHandler.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public function __construct(
2626
) {
2727
}
2828

29-
public function handle(string $branchName): ObjectId
29+
public function handle(string $branchName): MergeResult
3030
{
3131
$theirsRef = RefName::branch($branchName);
3232
$theirsId = $this->repository->refs->resolve($theirsRef);
@@ -39,10 +39,10 @@ public function mergeRef(string $refPath): ObjectId
3939
$theirsId = $this->repository->refs->resolve(RefName::fromString($refPath));
4040
$label = str_starts_with($refPath, 'refs/remotes/') ? substr($refPath, 13) : $refPath;
4141

42-
return $this->doMerge($theirsId, $label);
42+
return $this->doMerge($theirsId, $label)->commitId;
4343
}
4444

45-
private function doMerge(ObjectId $theirsId, string $label): ObjectId
45+
private function doMerge(ObjectId $theirsId, string $label): MergeResult
4646
{
4747
$oursId = $this->repository->refs->resolve(RefName::head());
4848

@@ -54,10 +54,14 @@ private function doMerge(ObjectId $theirsId, string $label): ObjectId
5454
$baseId = $resolver->findMergeBase($oursId, $theirsId);
5555

5656
if ($baseId instanceof ObjectId && $baseId->equals($oursId)) {
57-
return $this->fastForward($theirsId);
57+
$commitId = $this->fastForward($theirsId);
58+
59+
return new MergeResult(commitId: $commitId, fastForward: true, oldId: $oursId);
5860
}
5961

60-
return $this->threeWayMerge($oursId, $theirsId, $baseId, $label);
62+
$commitId = $this->threeWayMerge($oursId, $theirsId, $baseId, $label);
63+
64+
return new MergeResult(commitId: $commitId, fastForward: false, oldId: $oursId);
6165
}
6266

6367
private function fastForward(ObjectId $targetId): ObjectId
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Lukasojd\PureGit\Application\Handler;
6+
7+
use Lukasojd\PureGit\Domain\Object\ObjectId;
8+
9+
final readonly class MergeResult
10+
{
11+
public function __construct(
12+
public ObjectId $commitId,
13+
public bool $fastForward,
14+
public ObjectId $oldId,
15+
) {
16+
}
17+
}

src/Application/Handler/ShowHandler.php

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
namespace Lukasojd\PureGit\Application\Handler;
66

77
use Lukasojd\PureGit\Application\Service\Repository;
8+
use Lukasojd\PureGit\Domain\Exception\PureGitException;
89
use Lukasojd\PureGit\Domain\Object\GitObject;
910
use Lukasojd\PureGit\Domain\Object\ObjectId;
11+
use Lukasojd\PureGit\Domain\Object\Tag;
1012
use Lukasojd\PureGit\Domain\Ref\RefName;
1113

1214
final readonly class ShowHandler
@@ -18,22 +20,78 @@ public function __construct(
1820

1921
public function handle(?string $target = null): GitObject
2022
{
21-
if ($target !== null) {
22-
try {
23-
$id = ObjectId::fromHex($target);
23+
if ($target === null) {
24+
$headId = $this->repository->refs->resolve(RefName::head());
2425

25-
return $this->repository->objects->read($id);
26-
} catch (\Throwable) {
27-
// Try as ref
28-
$ref = RefName::fromString($target);
26+
return $this->repository->objects->read($headId);
27+
}
28+
29+
return $this->resolveTarget($target);
30+
}
31+
32+
private function resolveTarget(string $target): GitObject
33+
{
34+
// Try as full hex hash
35+
if ($this->isFullHex($target)) {
36+
return $this->repository->objects->read(ObjectId::fromHex($target));
37+
}
38+
39+
// Try as ref with common prefixes
40+
$object = $this->tryResolveRef($target);
41+
if ($object instanceof GitObject) {
42+
return $this->peelTag($object);
43+
}
44+
45+
// Try as short hash prefix
46+
$object = $this->tryResolvePrefix($target);
47+
if ($object instanceof GitObject) {
48+
return $object;
49+
}
50+
51+
throw new PureGitException(sprintf('Reference not found: %s', $target));
52+
}
53+
54+
private function tryResolveRef(string $target): ?GitObject
55+
{
56+
$prefixes = ['', 'refs/', 'refs/heads/', 'refs/tags/', 'refs/remotes/'];
57+
58+
foreach ($prefixes as $prefix) {
59+
$ref = RefName::fromString($prefix . $target);
60+
if ($this->repository->refs->exists($ref)) {
2961
$id = $this->repository->refs->resolve($ref);
3062

3163
return $this->repository->objects->read($id);
3264
}
3365
}
3466

35-
$headId = $this->repository->refs->resolve(RefName::head());
67+
return null;
68+
}
69+
70+
private function tryResolvePrefix(string $target): ?GitObject
71+
{
72+
if (strlen($target) < 4 || ! ctype_xdigit($target)) {
73+
return null;
74+
}
75+
76+
$id = $this->repository->objects->findByPrefix($target);
77+
if ($id instanceof ObjectId) {
78+
return $this->repository->objects->read($id);
79+
}
3680

37-
return $this->repository->objects->read($headId);
81+
return null;
82+
}
83+
84+
private function peelTag(GitObject $object): GitObject
85+
{
86+
if ($object instanceof Tag) {
87+
return $this->repository->objects->read($object->targetId);
88+
}
89+
90+
return $object;
91+
}
92+
93+
private function isFullHex(string $value): bool
94+
{
95+
return strlen($value) === 40 && ctype_xdigit($value);
3896
}
3997
}

src/CLI/Command/DiffCommand.php

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66

77
use Lukasojd\PureGit\Application\Handler\DiffHandler;
88
use Lukasojd\PureGit\Application\Service\Repository;
9+
use Lukasojd\PureGit\Domain\Diff\FileDiff;
10+
use Lukasojd\PureGit\Domain\Diff\FileStatus;
911
use Lukasojd\PureGit\Infrastructure\Diff\MyersDiffAlgorithm;
1012

1113
final class DiffCommand implements CliCommand
1214
{
15+
private const string ZERO_HASH_SHORT = '0000000';
16+
1317
public function name(): string
1418
{
1519
return 'diff';
@@ -45,17 +49,47 @@ public function execute(array $args): int
4549
$diffs = $cached ? $handler->diffIndexVsHead() : $handler->diffWorkingVsIndex();
4650

4751
foreach ($diffs as $diff) {
48-
fwrite(STDOUT, sprintf("diff --puregit a/%s b/%s\n", $diff->path, $diff->path));
52+
$this->printFileDiff($diff);
53+
}
54+
55+
return 0;
56+
}
57+
58+
public function printFileDiff(FileDiff $diff): void
59+
{
60+
fwrite(STDOUT, sprintf("diff --git a/%s b/%s\n", $diff->path, $diff->path));
61+
62+
$this->printModeAndIndex($diff);
63+
$this->printFileHeaders($diff);
4964

50-
foreach ($diff->hunks as $hunk) {
51-
fwrite(STDOUT, $hunk->header() . "\n");
65+
foreach ($diff->hunks as $hunk) {
66+
fwrite(STDOUT, $hunk->header() . "\n");
5267

53-
foreach ($hunk->lines as $line) {
54-
fwrite(STDOUT, sprintf("%s%s\n", $line->type->value, $line->content));
55-
}
68+
foreach ($hunk->lines as $line) {
69+
fwrite(STDOUT, sprintf("%s%s\n", $line->type->value, $line->content));
5670
}
5771
}
72+
}
5873

59-
return 0;
74+
private function printModeAndIndex(FileDiff $diff): void
75+
{
76+
if ($diff->status === FileStatus::Added) {
77+
fwrite(STDOUT, "new file mode 100644\n");
78+
} elseif ($diff->status === FileStatus::Deleted) {
79+
fwrite(STDOUT, "deleted file mode 100644\n");
80+
}
81+
82+
$oldShort = $diff->oldId instanceof \Lukasojd\PureGit\Domain\Object\ObjectId ? $diff->oldId->short(7) : self::ZERO_HASH_SHORT;
83+
$newShort = $diff->newId instanceof \Lukasojd\PureGit\Domain\Object\ObjectId ? $diff->newId->short(7) : self::ZERO_HASH_SHORT;
84+
$mode = ($diff->status !== FileStatus::Added && $diff->status !== FileStatus::Deleted) ? ' 100644' : '';
85+
fwrite(STDOUT, sprintf("index %s..%s%s\n", $oldShort, $newShort, $mode));
86+
}
87+
88+
private function printFileHeaders(FileDiff $diff): void
89+
{
90+
$oldPath = $diff->status === FileStatus::Added ? '/dev/null' : 'a/' . $diff->path;
91+
$newPath = $diff->status === FileStatus::Deleted ? '/dev/null' : 'b/' . $diff->path;
92+
fwrite(STDOUT, sprintf("--- %s\n", $oldPath));
93+
fwrite(STDOUT, sprintf("+++ %s\n", $newPath));
6094
}
6195
}

src/CLI/Command/LogCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public function execute(array $args): int
5353
foreach ($commits as $commit) {
5454
fwrite(STDOUT, sprintf("commit %s\n", $commit->getId()->hash));
5555
fwrite(STDOUT, sprintf("Author: %s <%s>\n", $commit->author->name, $commit->author->email));
56-
fwrite(STDOUT, sprintf("Date: %s\n", $commit->author->timestamp->format('Y-m-d H:i:s O')));
56+
fwrite(STDOUT, sprintf("Date: %s\n", $commit->author->timestamp->format('D M j H:i:s Y O')));
5757
fwrite(STDOUT, sprintf("\n %s\n\n", $commit->message));
5858
}
5959

0 commit comments

Comments
 (0)