From 98c33fb3cc5973f242618937582d9cd7b0ec40ad Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Tue, 21 May 2024 16:19:14 +0100 Subject: [PATCH] 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`. --- packages/git/src/format.js | 177 ++++++++++++++++++++ packages/git/src/subcommands/__exports__.js | 2 + packages/git/src/subcommands/log.js | 64 +++++++ 3 files changed, 243 insertions(+) create mode 100644 packages/git/src/format.js create mode 100644 packages/git/src/subcommands/log.js diff --git a/packages/git/src/format.js b/packages/git/src/format.js new file mode 100644 index 00000000..fe79d582 --- /dev/null +++ b/packages/git/src/format.js @@ -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 . + */ +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}`); + } + } +} diff --git a/packages/git/src/subcommands/__exports__.js b/packages/git/src/subcommands/__exports__.js index a2fb2741..25cdba9f 100644 --- a/packages/git/src/subcommands/__exports__.js +++ b/packages/git/src/subcommands/__exports__.js @@ -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, }; diff --git a/packages/git/src/subcommands/log.js b/packages/git/src/subcommands/log.js new file mode 100644 index 00000000..dfe6f240 --- /dev/null +++ b/packages/git/src/subcommands/log.js @@ -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 . + */ +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 [...] [--max-count ] ', + 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)); + } + } +}