Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
710fbeaaeThis file is pinned to the selected release. Download the original to open it in your design tool.
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:harness/core/git_worktree.dart';
import 'package:harness/core/project_folder.dart';
import 'package:harness/core/repository_clone.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory root;
late String repo;
Future<String> git(List<String> args, [String? folder]) async {
final result = await Process.run('git', ['-C', folder ?? repo, ...args]);
expect(result.exitCode, 0, reason: '${args.join(' ')}: ${result.stderr}');
return (result.stdout as String).trim();
}
setUp(() async {
root = await Directory.systemTemp.createTemp('harness-git-test-');
repo = p.join(root.path, 'project with spaces');
await Directory(p.join(repo, 'src')).create(recursive: true);
await git(['init', '-b', 'main']);
await git(['config', 'user.name', 'Test']);
await git(['config', 'user.email', 'test@example.invalid']);
await git(['config', 'commit.gpgsign', 'false']);
await git(['config', 'core.hooksPath', '/dev/null']);
await File(p.join(repo, 'src', 'value')).writeAsString('main');
await git(['add', '.']);
await git(['commit', '-m', 'initial']);
await git(['switch', '-c', 'feature']);
await File(p.join(repo, 'src', 'value')).writeAsString('feature');
await git(['commit', '-am', 'feature']);
await git(['update-ref', 'refs/remotes/origin/feature', 'HEAD']);
await git([
'symbolic-ref',
'refs/remotes/origin/HEAD',
'refs/remotes/origin/feature',
]);
await git(['switch', 'main']);
});
tearDown(() => root.delete(recursive: true));
Future<String> worktree({
String ref = 'refs/heads/feature',
String? folder,
String? name,
bool existing = false,
}) => ProjectFolderRequest.worktree(
folder ?? repo,
branchRef: ref,
branchName: name,
existingBranch: existing,
).prepareLocal(projectHome: p.join(root.path, 'harnesses'));
String worktrees() =>
p.join(root.path, 'harnesses', 'worktrees', 'project with spaces');
test(
'local Git discovery is read only and excludes symbolic remote refs',
() async {
final info = GitProjectInfo.fromJson(await readLocalGitProject(repo));
expect(info.isGit, isTrue);
expect(info.branch, 'main');
expect(info.branches.map((branch) => branch.name), [
'feature',
'main',
'origin/feature',
]);
expect(info.branches.last.remote, isTrue);
expect(info.defaultRef, 'refs/remotes/origin/feature');
expect(await git(['branch', '--show-current']), 'main');
expect((await readLocalGitProject(root.path))['isGit'], false);
expect((await readLocalGitProject('relative'))['error'], 'INVALID_PATH');
},
);
test(
'discovers newly pushed branches without fetching until Start',
() async {
final remote = p.join(root.path, 'remote.git');
final clone = p.join(root.path, 'other computer');
await git(['clone', '--bare', repo, remote]);
await git([
'clone',
'--no-local',
'--single-branch',
'--branch',
'main',
remote,
clone,
]);
await git(['remote', 'add', 'origin', remote]);
await git(['switch', '-c', 'feat/toolbar-onboarding']);
await File(p.join(repo, 'src/value')).writeAsString('new remote work');
await git(['commit', '-am', 'new work']);
await git(['push', 'origin', 'feat/toolbar-onboarding']);
await File(p.join(clone, 'src/value'))
.writeAsString('unsaved local work');
final refs = await git(['show-ref'], clone);
final saved = GitProjectInfo.fromJson(await readLocalGitProject(clone));
expect(
saved.branches.map((b) => b.name),
isNot(contains('origin/feat/toolbar-onboarding')),
);
final data = await readLocalGitProject(clone, refresh: true);
final fresh = GitProjectInfo.fromJson(data);
expect(data['refreshed'], true);
expect(
fresh.branches.map((b) => b.name),
contains('origin/feat/toolbar-onboarding'),
);
expect(
await git(['show-ref'], clone),
refs,
reason: 'Looking up names must not download objects or write refs.',
);
expect(
await File(p.join(clone, 'src/value')).readAsString(),
'unsaved local work',
);
final path = await worktree(
folder: clone,
ref: 'refs/remotes/origin/feat/toolbar-onboarding',
name: 'feat/toolbar-onboarding',
);
expect(
await File(p.join(path, 'src/value')).readAsString(),
'new remote work',
);
expect(await git(['branch', '--show-current'], clone), 'main');
},
);
test('refresh coalesces requests for one repository and preserves offline choices', () async {
final remote = p.join(root.path, 'remote.git');
await git(['clone', '--bare', repo, remote]);
await git(['remote', 'add', 'origin', remote]);
final entered = Completer<void>();
final release = Completer<void>();
var lookups = 0;
Future<Process> start(List<String> args, Map<String, String> env) async {
if (args.contains('ls-remote')) {
lookups++;
if (!entered.isCompleted) entered.complete();
await release.future;
}
return Process.start(
'git',
args,
environment: env,
includeParentEnvironment: false,
);
}
final first = readLocalGitProject(repo, refresh: true, startProcess: start);
await entered.future;
final second = readLocalGitProject(
repo,
refresh: true,
startProcess: start,
);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(lookups, 1);
release.complete();
final answers = await Future.wait([first, second]);
expect(answers.every((answer) => answer['refreshed'] == true), true);
await git([
'remote',
'set-url',
'origin',
p.join(root.path, 'missing.git'),
]);
final offline = await readLocalGitProject(repo, refresh: true);
expect(offline['refreshed'], false);
expect(
GitProjectInfo.fromJson(offline).branches.map((b) => b.name),
contains('origin/feature'),
);
});
test('successful refresh removes deleted remote choices and keeps local branches', () async {
final remote = p.join(root.path, 'remote.git');
await git(['clone', '--bare', repo, remote]);
await git(['remote', 'add', 'origin', remote]);
await git(['branch', '-D', 'feature'], remote);
final info = GitProjectInfo.fromJson(
await readLocalGitProject(repo, refresh: true),
);
expect(info.branches.map((b) => b.name), contains('feature'));
expect(info.branches.map((b) => b.name), isNot(contains('origin/feature')));
expect(
await git(['show-ref', '--verify', 'refs/remotes/origin/feature']),
isNotEmpty,
);
});
test('concurrent worktrees use the selected ref, new branches, and preserve dirty source files', () async {
await File(p.join(repo, 'src', 'value')).writeAsString('keep my work');
final paths = await Future.wait([
worktree(),
worktree(ref: 'refs/remotes/origin/feature'),
]);
expect(paths.toSet(), hasLength(2));
final branches = <String>{};
for (final path in paths) {
expect(
await File(p.join(path, 'src', 'value')).readAsString(),
'feature',
);
final branch = await git(['branch', '--show-current'], path);
expect(branch, matches(RegExp(r'^[a-z]+-[a-z]+(-\d+)?$')));
branches.add(branch);
}
expect(branches, hasLength(2));
expect(
await File(p.join(repo, 'src', 'value')).readAsString(),
'keep my work',
);
expect(await git(['branch', '--show-current']), 'main');
});
test(
'a subfolder follows the selected branch into its new worktree',
() async {
final path = await worktree(folder: p.join(repo, 'src'));
expect(await File(p.join(path, 'value')).readAsString(), 'feature');
await Directory(p.join(repo, 'untracked')).create();
await expectLater(
worktree(folder: p.join(repo, 'untracked')),
throwsA(isA<RepositoryCloneException>()),
);
},
);
test('branch switching protects dirty changes', () async {
Future<String> switchTo(String branch) => ProjectFolderRequest.branch(
repo,
'refs/heads/$branch',
).prepareLocal(projectHome: root.path);
expect(await switchTo('feature'), repo);
await File(p.join(repo, 'src', 'value')).writeAsString('keep this');
await expectLater(
switchTo('main'),
throwsA(isA<RepositoryCloneException>()),
);
expect(
await File(p.join(repo, 'src', 'value')).readAsString(),
'keep this',
);
expect(await git(['branch', '--show-current']), 'feature');
expect(await git(['stash', 'list']), isEmpty);
expect(await switchTo('feature'), repo);
await git(['checkout', '--', '.']);
await File(p.join(repo, 'notes.txt')).writeAsString('untracked');
await expectLater(
switchTo('main'),
throwsA(isA<RepositoryCloneException>()),
);
expect(await git(['branch', '--show-current']), 'feature');
});
test(
'a new branch for the folder itself keeps its uncommitted work',
() async {
await File(p.join(repo, 'src', 'value')).writeAsString('in progress');
final path = await ProjectFolderRequest.branch(
repo,
'refs/heads/login-fix',
newBranch: 'login-fix',
).prepareLocal(projectHome: root.path);
expect(path, repo);
expect(await git(['branch', '--show-current']), 'login-fix');
expect(
await File(p.join(repo, 'src', 'value')).readAsString(),
'in progress',
);
await expectLater(
ProjectFolderRequest.branch(
repo,
'refs/heads/feature',
newBranch: 'feature',
).prepareLocal(projectHome: root.path),
throwsA(isA<RepositoryCloneException>()),
);
},
);
Future<String> real(String path) => Directory(path).resolveSymbolicLinks();
test('a branch checked out in another worktree opens there', () async {
final other = p.join(root.path, 'other');
await git(['worktree', 'add', other, 'feature']);
final path = await ProjectFolderRequest.branch(
p.join(repo, 'src'),
'refs/heads/feature',
).prepareLocal(projectHome: root.path);
expect(await real(path), await real(p.join(other, 'src')));
expect(await git(['branch', '--show-current']), 'main');
});
test('placeholder branches are two words no branch uses yet', () {
expect(placeholderBranch(const [], random: _First()), 'amber-badger');
expect(
placeholderBranch(const [
'refs/heads/amber-badger',
'amber-badger-2',
], random: _First()),
'amber-badger-3',
);
expect(worktreeFolderName('brave-otter'), 'brave-otter');
expect(worktreeFolderName('fix/login page'), 'loginpage');
});
test('new worktrees are on a new branch, in a folder named for it, grouped by repository', () async {
final made = [await worktree(), await worktree()];
expect(made.toSet(), hasLength(2));
for (final path in made) {
final branch = await git(['branch', '--show-current'], path);
expect(branch, matches(RegExp(r'^[a-z]+-[a-z]+(-\d+)?$')));
expect(
await git(['config', '--get', 'branch.$branch.harness']),
'placeholder',
);
expect(path, p.join(worktrees(), worktreeFolderName(branch)));
expect(
await git(['rev-parse', 'HEAD'], path),
await git(['rev-parse', 'feature']),
);
}
final named = await worktree(name: 'fix/login');
expect(named, p.join(worktrees(), 'login'));
expect(await git(['branch', '--show-current'], named), 'fix/login');
expect(
await git(['config', '--get', 'branch.fix/login.harness']),
'created',
);
final info = GitProjectInfo.fromJson(await readLocalGitProject(repo));
expect(
[
for (final b in info.branches)
if (b.harness) b.name,
]..sort(),
[...made.map(p.basename), 'fix/login']..sort(),
reason: 'Every branch Harness made, and only those.',
);
await expectLater(
worktree(name: 'fix/login'),
throwsA(isA<RepositoryCloneException>()),
);
await expectLater(
worktree(name: 'bad..name'),
throwsA(isA<RepositoryCloneException>()),
);
});
test(
'an existing branch is checked out as it is in a new worktree, once',
() async {
await git(['branch', 'topic', 'main']);
final path = await worktree(
ref: 'refs/heads/topic',
name: 'topic',
existing: true,
);
expect(path, p.join(worktrees(), 'topic'));
expect(await git(['branch', '--show-current'], path), 'topic');
expect(await File(p.join(path, 'src', 'value')).readAsString(), 'main');
await expectLater(
worktree(ref: 'refs/heads/topic', name: 'topic', existing: true),
throwsA(isA<RepositoryCloneException>()),
);
},
);
test('a remote base is fetched first; a remote branch is tracked under its own name', () async {
final origin = p.join(root.path, 'origin.git');
final upstream = p.join(root.path, 'upstream');
await git(['clone', '--quiet', '--bare', repo, origin], root.path);
await git(['remote', 'add', 'origin', origin]);
await git(['fetch', '--quiet', 'origin']);
await git(['clone', '--quiet', origin, upstream], root.path);
for (final args in [
['config', 'user.name', 'Test'],
['config', 'user.email', 'test@example.invalid'],
['config', 'commit.gpgsign', 'false'],
['config', 'core.hooksPath', '/dev/null'],
]) {
await git(args, upstream);
}
await File(p.join(upstream, 'src', 'value')).writeAsString('pushed');
await git(['commit', '-qam', 'pushed'], upstream);
await git(['push', '--quiet', 'origin', 'main', 'main:fix/typo'], upstream);
await git([
'fetch',
'--quiet',
'origin',
'fix/typo:refs/remotes/origin/fix/typo',
]);
final fresh = await worktree(
ref: 'refs/remotes/origin/main',
name: 'harness/fresh',
);
expect(
await File(p.join(fresh, 'src', 'value')).readAsString(),
'pushed',
reason: 'origin/main was fetched before the worktree started from it.',
);
final plain = await Process.run('git', [
'-C',
fresh,
'rev-parse',
'--abbrev-ref',
'@{upstream}',
]);
expect(
plain.exitCode,
isNot(0),
reason: 'A new branch never pushes onto its base.',
);
// A local branch starts from the newer of itself and its upstream.
await git(['branch', '--set-upstream-to=origin/main', 'main']);
await git(['update-ref', 'refs/remotes/origin/main', 'main']);
final behind = await worktree(ref: 'refs/heads/main', name: 'from-behind');
expect(
await File(p.join(behind, 'src', 'value')).readAsString(),
'pushed',
reason: 'main was behind origin/main, fetched at Start.',
);
await File(p.join(repo, 'src', 'value')).writeAsString('local');
await git(['commit', '-qam', 'local']);
final ahead = await worktree(ref: 'refs/heads/main', name: 'from-ahead');
expect(
await File(p.join(ahead, 'src', 'value')).readAsString(),
'local',
reason: 'A commit only main has is never left behind.',
);
final tracking = await worktree(
ref: 'refs/remotes/origin/fix/typo',
name: 'fix/typo',
);
expect(
await git(['rev-parse', '--abbrev-ref', '@{upstream}'], tracking),
'origin/fix/typo',
);
});
test(
'ignored files named in .worktreeinclude are copied into new worktrees',
() async {
await File(p.join(repo, '.gitignore'))
.writeAsString('.env\n*.log\nlocal/\n');
await File(p.join(repo, '.worktreeinclude'))
.writeAsString('.env\nlocal/\n');
await File(p.join(repo, '.env')).writeAsString('SECRET=1');
await File(p.join(repo, 'debug.log')).writeAsString('noise');
await Directory(p.join(repo, 'local')).create();
await File(p.join(repo, 'local', 'settings.json')).writeAsString('{}');
final path = await worktree();
expect(await File(p.join(path, '.env')).readAsString(), 'SECRET=1');
expect(
await File(p.join(path, 'local', 'settings.json')).readAsString(),
'{}',
);
expect(await File(p.join(path, 'debug.log')).exists(), false);
},
);
test('a linked worktree reads as its repository, and new worktrees from it join that repository', () async {
final linked = await worktree(name: 'harness/linked');
final info = GitProjectInfo.fromJson(
await readLocalGitProject(p.join(linked, 'src')),
);
expect(info.branch, 'harness/linked');
expect(info.mainBranch, 'main');
expect(await real(info.mainFolder!), await real(p.join(repo, 'src')));
final branches = {for (final b in info.branches) b.name: b.worktree};
expect(await real(branches['harness/linked']!), await real(linked));
expect(await real(branches['main']!), await real(repo));
expect(branches['origin/feature'], isNull);
expect(
GitProjectInfo.fromJson(await readLocalGitProject(repo)).mainFolder,
isNull,
);
expect(
await worktree(folder: linked, name: 'harness/second'),
p.join(worktrees(), 'second'),
);
});
test('missing branches and revision expressions cannot silently select another commit', () async {
for (final ref in [
'refs/heads/missing',
'refs/heads/main~0',
'refs/heads/main^{commit}',
]) {
await expectLater(
worktree(ref: ref),
throwsA(isA<RepositoryCloneException>()),
);
}
final empty = await Directory(p.join(root.path, 'empty')).create();
await git(['init', '-b', 'main'], empty.path);
expect((await readLocalGitProject(empty.path))['isGit'], true);
await expectLater(
ProjectFolderRequest.worktree(empty.path)
.prepareLocal(projectHome: root.path),
throwsA(isA<RepositoryCloneException>()),
);
});
}
/// Always the first word of each list.
class _First implements Random {
@override
int nextInt(int max) => 0;
@override
double nextDouble() => 0;
@override
bool nextBool() => false;
}