Implement git log

This is quite barebones for now.

Commit formatting is done in a separate file, as this is used by other
git commands, such as `show`.
This commit is contained in:
Sam Atkins 2024-05-21 16:19:14 +01:00
parent b4e2ba4544
commit 98c33fb3cc
3 changed files with 243 additions and 0 deletions

177
packages/git/src/format.js Normal file
View File

@ -0,0 +1,177 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { shorten_hash } from './git-helpers.js';
export const commit_formatting_options = {
'abbrev-commit': {
description: 'Display commit hashes in abbreviated form.',
type: 'boolean',
},
'no-abbrev-commit': {
description: 'Always show full commit hashes.',
type: 'boolean',
},
'format': {
description: 'Format to use for commits.',
type: 'string',
},
'oneline': {
description: 'Shorthand for "--format=oneline --abbrev-commit".',
type: 'boolean',
},
};
/**
* Process command-line options related to commit formatting, and modify them in place.
* May throw if the options are in some way invalid.
* @param options Parsed command-line options, which will be modified in place.
*/
export const process_commit_formatting_options = (options) => {
if (options.oneline) {
options.format = 'oneline';
options['abbrev-commit'] = true;
}
options.short_hashes = (options['abbrev-commit'] === true) && (options['no-abbrev-commit'] !== true);
delete options['abbrev-commit'];
delete options['no-abbrev-commit'];
if (!options.format) {
options.format = 'medium';
}
if (!['oneline', 'short', 'medium', 'full', 'fuller', 'raw'].includes(options.format)) {
throw new Error(`Invalid --format format: ${options.format}`);
}
}
/**
* Format the given oid hash, followed by any refs that point to it
* @param oid
* @param short_hashes Whwther to shorten the hash
* @returns {String}
*/
export const format_oid = (oid, { short_hashes = false } = {}) => {
// TODO: List refs at this commit, after the hash
return short_hashes ? shorten_hash(oid) : oid;
}
/**
* Format the person's name and email as `${name} <${email}>`
* @param person
* @returns {`${string} <${string}>`}
*/
export const format_person = (person) => {
return `${person.name} <${person.email}>`;
}
/**
* Format a date
* @param date
* @param options
* @returns {string}
*/
export const format_date = (date, options = {}) => {
// TODO: This needs to obey date-format options, and should show the correct timezone not UTC
return new Date(date.timestamp * 1000).toUTCString();
}
/**
* Format the date, according to the "raw" display format.
* @param owner
* @returns {`${string} ${string}${string}${string}`}
*/
export const format_timestamp_and_offset = (owner) => {
// FIXME: The timezone offset is inverted.
// Either this is correct here, or we should be inverting it when creating the commit -
// Isomorphic git uses (new Date()).timezoneOffset() there, which returns -60 for BST, which is UTC+0100
const offset = -owner.timezoneOffset;
const offset_hours = Math.floor(offset / 60);
const offset_minutes = offset % 60;
const pad = (number) => `${Math.abs(number) < 10 ? '0' : ''}${Math.abs(number)}`;
return `${owner.timestamp} ${offset < 0 ? '-' : '+'}${pad(offset_hours)}${pad(offset_minutes)}`;
}
/**
* Produce a string representation of a commit.
* @param commit A CommitObject
* @param oid Commit hash
* @param options Options returned by parsing the command arguments in `commit_formatting_options`
* @returns {string}
*/
export const format_commit = (commit, oid, options = {}) => {
const title_line = () => commit.message.split('\n')[0];
switch (options.format || 'medium') {
// TODO: Other formats
case 'oneline':
return `${format_oid(oid, options)} ${title_line()}`;
case 'short': {
let s = '';
s += `commit ${format_oid(oid, options)}\n`;
s += `Author: ${format_person(commit.author)}\n`;
s += '\n';
s += title_line();
return s;
}
case 'medium': {
let s = '';
s += `commit ${format_oid(oid, options)}\n`;
s += `Author: ${format_person(commit.author)}\n`;
s += `Date: ${format_date(commit.author)}\n`;
s += '\n';
s += commit.message;
return s;
}
case 'full': {
let s = '';
s += `commit ${format_oid(oid, options)}\n`;
s += `Author: ${format_person(commit.author)}\n`;
s += `Commit: ${format_person(commit.committer)}\n`;
s += '\n';
s += commit.message;
return s;
}
case 'fuller': {
let s = '';
s += `commit ${format_oid(oid, options)}\n`;
s += `Author: ${format_person(commit.author)}\n`;
s += `AuthorDate: ${format_date(commit.author)}\n`;
s += `Commit: ${format_person(commit.committer)}\n`;
s += `CommitDate: ${format_date(commit.committer)}\n`;
s += '\n';
s += commit.message;
return s;
}
case 'raw': {
let s = '';
s += `commit ${oid}\n`;
s += `tree ${commit.tree}\n`;
if (commit.parent[0])
s += `parent ${commit.parent[0]}\n`;
s += `author ${format_person(commit.author)} ${format_timestamp_and_offset(commit.author)}\n`;
s += `committer ${format_person(commit.committer)} ${format_timestamp_and_offset(commit.committer)}\n`;
s += '\n';
s += commit.message;
return s;
}
default: {
throw new Error(`Invalid --format format: ${options.format}`);
}
}
}

View File

@ -21,6 +21,7 @@ import module_add from './add.js'
import module_commit from './commit.js'
import module_help from './help.js'
import module_init from './init.js'
import module_log from './log.js'
import module_status from './status.js'
import module_version from './version.js'
@ -29,6 +30,7 @@ export default {
"commit": module_commit,
"help": module_help,
"init": module_init,
"log": module_log,
"status": module_status,
"version": module_version,
};

View File

@ -0,0 +1,64 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import git from 'isomorphic-git';
import { find_repo_root } from '../git-helpers.js';
import { commit_formatting_options, format_commit, process_commit_formatting_options } from '../format.js';
export default {
name: 'log',
usage: 'git log [<formatting-option>...] [--max-count <n>] <revision>',
description: 'Show commit logs, starting at the given revision.',
args: {
allowPositionals: false,
options: {
...commit_formatting_options,
'max-count': {
description: 'Maximum number of commits to output.',
type: 'string',
short: 'n',
},
},
},
execute: async (ctx) => {
const { io, fs, env, args } = ctx;
const { stdout, stderr } = io;
const { options, positionals } = args;
process_commit_formatting_options(options);
// TODO: Log of a specific file
// TODO: Log of a specific branch
// TODO: Log of a specific commit
const depth = Number(options['max-count']) || undefined;
const { repository_dir, git_dir } = await find_repo_root(fs, env.PWD);
const log = await git.log({
fs,
dir: repository_dir,
gitdir: git_dir,
depth,
});
for (const commit of log) {
stdout(format_commit(commit.commit, commit.oid, options));
}
}
}