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));
+ }
+ }
+}