Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/labfox/lib/features/commits/data/history_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,35 @@ class HistoryRepository {
return List<CommitDiffFile>.unmodifiable(items);
}

/// Reads fixed-head detail through this captured account and project.
/// The caller chooses the target from fresh authoritative MR context.
/// Null means the caller's origin expired; late results/errors are discarded.
/// Recheck currency after awaiting, then validate membership and original
/// references separately. This read does not make a private write safe.
Future<Commit?> originalCommit({
required int projectId,
required String commitId,
required bool Function() isCurrent,
}) async {
if (projectId < 1) throw ArgumentError.value(projectId, 'projectId');
if (commitId.length != 40 || RegExp(r'[^0-9a-f]').hasMatch(commitId)) {
throw ArgumentError.value(commitId, 'commitId');
}
if (!isCurrent()) return null;
final Commit result;
try {
result = await _client.repository.fixedCommit(
projectId,
commitId: commitId,
);
} on GitLabException {
if (!isCurrent()) return null;
rethrow;
}
if (!isCurrent()) return null;
return result;
}

Future<Commit> commit({required int projectId, required String sha}) {
return _client.repository.commit(projectId, sha: sha);
}
Expand Down
127 changes: 127 additions & 0 deletions apps/labfox/test/original_commit_repository_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:gitlab_api/gitlab_api.dart';
import 'package:labfox/features/commits/data/history_repository.dart';
import '../../../packages/gitlab_api/test/mr_commits_api_test.dart' as b;

void main() {
for (final parents in [
null,
<String>[],
[b.parent],
[b.second, b.parent],
]) {
test('captured fixed detail preserves parent context $parents', () async {
var calls = 0;
final c = b.client((o) {
calls++;
expect(o.path, '/projects/8/repository/commits/${b.head}');
expect(o.method, 'GET');
return b.response({...b.commit(), 'parent_ids': parents});
});
addTearDown(c.close);
final result = await HistoryRepository(
c,
).originalCommit(projectId: 8, commitId: b.head, isCurrent: () => true);
expect(result!.id, b.head);
expect(result.parentIds, parents);
expect(calls, 1);
});
}
for (final (project, sha) in [
(0, b.head),
(-1, b.head),
(8, 'main'),
(8, ''),
(8, b.head.toUpperCase()),
]) {
test('invalid identity never dispatches $project $sha', () async {
var calls = 0;
final c = b.client((o) {
calls++;
return b.response(b.commit());
});
addTearDown(c.close);
await expectLater(
HistoryRepository(c).originalCommit(
projectId: project,
commitId: sha,
isCurrent: () => true,
),
throwsArgumentError,
);
expect(calls, 0);
});
}
test('obsolete origin performs no read', () async {
var calls = 0;
final c = b.client((o) {
calls++;
return b.response(b.commit());
});
addTearDown(c.close);
expect(
await HistoryRepository(
c,
).originalCommit(projectId: 8, commitId: b.head, isCurrent: () => false),
isNull,
);
expect(calls, 0);
});
for (final status in [200, 403, 404, 429, 500]) {
test('obsolete late success or failure is discarded $status', () async {
final started = Completer<void>(), answer = Completer<void>();
var current = true;
final c = b.client((o) async {
started.complete();
await answer.future;
return b.response(b.commit(), status: status);
});
addTearDown(c.close);
final result = HistoryRepository(c).originalCommit(
projectId: 8,
commitId: b.head,
isCurrent: () => current,
);
await Future.any<Object?>([started.future, result]);
expect(started.isCompleted, isTrue);
current = false;
answer.complete();
expect(await result, isNull);
});
}
test('final exposure rechecks currency', () async {
var checks = 0;
final c = b.client((o) => b.response(b.commit()));
addTearDown(c.close);
expect(
await HistoryRepository(c).originalCommit(
projectId: 8,
commitId: b.head,
isCurrent: () => ++checks < 2,
),
isNull,
);
expect(checks, 2);
});
test('current head mismatch stays a typed failure', () async {
final c = b.client((o) => b.response(b.commit(id: b.parent)));
addTearDown(c.close);
await expectLater(
HistoryRepository(
c,
).originalCommit(projectId: 8, commitId: b.head, isCurrent: () => true),
throwsA(isA<GitLabServerException>()),
);
});
test('current unavailable target commit stays not found', () async {
final c = b.client((o) => b.response('invalid', status: 404, raw: true));
addTearDown(c.close);
await expectLater(
HistoryRepository(
c,
).originalCommit(projectId: 8, commitId: b.head, isCurrent: () => true),
throwsA(isA<GitLabNotFoundException>()),
);
});
}
56 changes: 56 additions & 0 deletions docs/fixed-commit-review-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Fixed commit detail reads

Issue [#632](https://github.com/Theorvane/LabFox/issues/632) adds strict commit
detail reads for original review context. Existing general `commit` reads retain
branch/tag compatibility. No original diff coordinates or private-write UI is
exposed.

## Exact identity and parent metadata

The [official single commit endpoint](https://docs.gitlab.com/api/commits/#retrieve-a-commit)
uses `GET /projects/:id/repository/commits/:sha` and permits a commit hash or a
branch/tag reference. `RepositoryApi.fixedCommit` deliberately requires a full
lowercase SHA-1 and a positive numeric project or nonblank encoded path. It
requests `stats=false`, uses the captured instance/client, disables redirects
and checks HTTP 200 before decoding plain JSON. Returned `id` must exactly equal
the captured head. Missing/mismatched identities and malformed payloads fail
with sanitized domain errors; status/transport errors retain typed mapping.
Existing captured-account read-only OAuth recovery remains available on that
same fixed route.

Nullable generated `Commit.parentIds` retains its existing contract: absent or
null parents stay unknown, an empty array identifies a reported root commit, and
multiple parents retain server order. Every reported parent must be a full
lowercase SHA-1, distinct from the head and from other parents. Text is preserved
without trimming. The returned parent list is immutable through the existing
generated model; no serializer or model changes are required.

## Captured repository read

`HistoryRepository.originalCommit` requires a captured numeric project and head
plus a caller currency guard. It performs one strict detail GET through its
captured account client. The guard runs before dispatch and before successful
exposure or propagating a typed failure. Obsolete results/errors return null;
current failures stay typed. A guard cannot abort a dispatched GET or its
account-bound authentication recovery, and callers must recheck after awaiting.

The caller selects the target from fresh MR identity, not from a screen project,
source branch or list metadata. Availability of a fixed commit in that repository
does not establish current MR membership, original parent/reference semantics,
literal coordinates, complete diff coverage, permissions or an atomic snapshot.
Root/merge metadata is preserved without synthesizing blank/empty-tree references
or choosing a parent. MR-wide `diff_refs` are not a fallback. A future controller
must combine fresh identity/membership and exact original literal selection with
shared private-write reservations, settlement and explicit recovery.

## Validation

Test-first API/repository regressions cover encoded captured routes, exact head
and text preservation, unknown/root/ordered parents, invalid/self/duplicate
parents, input guards, immutable metadata, sanitized status-first failures,
redirect options, read-only OAuth recovery and origin replacement. A general
branch-reference read still works. Full workspace format, analysis and all five
package test gates apply. No UI/localization, dependencies, persistence, telemetry
or private-write behavior changes; live private-instance/device validation is
not claimed. Original reference validation and guarded review UI remain
follow-ups; MW-07 and image/file draft creation remain in progress.
2 changes: 1 addition & 1 deletion docs/mobile-web-parity.md

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions packages/gitlab_api/lib/src/repository/repository_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,64 @@ class RepositoryApi {
}
}

/// Reads an exact commit identity and preserves ordered, nullable parents.
/// General branch/tag detail reads use [commit] instead. This method does not
/// choose original diff references or prove current MR membership.
Future<Commit> fixedCommit(
Object projectId, {
required String commitId,
}) async {
if (!((projectId is int && projectId > 0) ||
(projectId is String && projectId.trim().isNotEmpty))) {
throw ArgumentError.value(projectId, 'projectId');
}
if (!_validCommitId(commitId)) {
throw ArgumentError.value(commitId, 'commitId');
}
try {
final response = await _dio.get<String>(
'/projects/${_enc(projectId)}/repository/commits/$commitId',
queryParameters: {'stats': false},
options: Options(
responseType: ResponseType.plain,
followRedirects: false,
),
);
if (response.statusCode != 200) {
throw mapStatus(
response.statusCode,
response.headers.map,
context: 'reading a fixed commit',
);
}
try {
final data = jsonDecode(response.data ?? '');
if (data is! Map<String, dynamic> || data['id'] != commitId) {
throw const FormatException();
}
final parents = data['parent_ids'];
if (parents != null &&
(parents is! List ||
parents.any((p) => !_validCommitId(p) || p == commitId) ||
parents.toSet().length != parents.length)) {
throw const FormatException();
}
return Commit.fromJson(data);
} on FormatException {
throw const GitLabServerException('Invalid fixed commit response.');
} on TypeError {
throw const GitLabServerException('Invalid fixed commit response.');
}
} on DioException catch (error) {
throw mapError(error, context: 'reading a fixed commit');
}
}

static bool _validCommitId(Object? value) =>
value is String &&
value.length == 40 &&
!RegExp(r'[^0-9a-f]').hasMatch(value);

/// Reads one page of literal original diff files for a fixed commit identity.
/// Preserves unknown text/omission metadata without inferring file coverage.
/// GitLab diff limits can stop pagination before every changed file is exposed.
Expand Down
Loading
Loading