From: "Fabian Grünbichler" <f.gruenbichler@proxmox.com>
To: Proxmox VE development discussion <pve-devel@lists.proxmox.com>
Subject: Re: [pve-devel] [PATCH v2 pve-storage 2/2] add lvmqcow2 plugin: (lvm with external qcow2 snapshot)
Date: Wed, 23 Oct 2024 12:13:28 +0200 (CEST) [thread overview]
Message-ID: <723920338.717.1729678408238@webmail.proxmox.com> (raw)
In-Reply-To: <mailman.143.1727695927.332.pve-devel@lists.proxmox.com>
I am not yet convinced this is somehow a good idea, but maybe you can convince me otherwise ;)
variant A: this is just useful for very short-lived snapshots
variant B: these snapshots are supposed to be long-lived
A is not something we want. we intentionally don't have non-thin LVM snapshots for example.
B once I create a single snapshot, the "original" storage only contains the data written up to that point, anything else is stored on the "snapshot" storage. this means my snapshot storage must be at least as fast/good/shared/.. as my original storage. in that case, I can just use the snapshot storage directly and ditch the original storage?
> Alexandre Derumier via pve-devel <pve-devel@lists.proxmox.com> hat am 30.09.2024 13:31 CEST geschrieben:
> Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
> ---
> src/PVE/Storage.pm | 2 +
> src/PVE/Storage/LvmQcow2Plugin.pm | 460 ++++++++++++++++++++++++++++++
> src/PVE/Storage/Makefile | 3 +-
> 3 files changed, 464 insertions(+), 1 deletion(-)
> create mode 100644 src/PVE/Storage/LvmQcow2Plugin.pm
>
> diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
> index 57b2038..119998f 100755
> --- a/src/PVE/Storage.pm
> +++ b/src/PVE/Storage.pm
> @@ -28,6 +28,7 @@ use PVE::Storage::Plugin;
> use PVE::Storage::DirPlugin;
> use PVE::Storage::LVMPlugin;
> use PVE::Storage::LvmThinPlugin;
> +use PVE::Storage::LvmQcow2Plugin;
> use PVE::Storage::NFSPlugin;
> use PVE::Storage::CIFSPlugin;
> use PVE::Storage::ISCSIPlugin;
> @@ -54,6 +55,7 @@ our $KNOWN_EXPORT_FORMATS = ['raw+size', 'tar+size', 'qcow2+size', 'vmdk+size',
> PVE::Storage::DirPlugin->register();
> PVE::Storage::LVMPlugin->register();
> PVE::Storage::LvmThinPlugin->register();
> +PVE::Storage::LvmQcow2Plugin->register();
> PVE::Storage::NFSPlugin->register();
> PVE::Storage::CIFSPlugin->register();
> PVE::Storage::ISCSIPlugin->register();
> diff --git a/src/PVE/Storage/LvmQcow2Plugin.pm b/src/PVE/Storage/LvmQcow2Plugin.pm
> new file mode 100644
> index 0000000..68c8686
> --- /dev/null
> +++ b/src/PVE/Storage/LvmQcow2Plugin.pm
> @@ -0,0 +1,460 @@
> +package PVE::Storage::LvmQcow2Plugin;
> +
> +use strict;
> +use warnings;
> +
> +use IO::File;
> +
> +use PVE::Tools qw(run_command trim);
> +use PVE::Storage::Plugin;
> +use PVE::Storage::LVMPlugin;
> +use PVE::JSONSchema qw(get_standard_option);
> +
> +use base qw(PVE::Storage::LVMPlugin);
> +
> +# Configuration
> +
> +sub type {
> + return 'lvmqcow2';
> +}
> +
> +sub plugindata {
> + return {
> + #container not yet implemented #need to implemented dm-qcow2
> + content => [ {images => 1, rootdir => 1}, { images => 1 }],
> + };
> +}
> +
> +sub properties {
> + return {
> + };
> +}
> +
> +sub options {
> + return {
> + vgname => { fixed => 1 },
> + nodes => { optional => 1 },
> + shared => { optional => 1 },
> + disable => { optional => 1 },
> + saferemove => { optional => 1 },
> + saferemove_throughput => { optional => 1 },
> + content => { optional => 1 },
> + base => { fixed => 1, optional => 1 },
> + tagged_only => { optional => 1 },
> + bwlimit => { optional => 1 },
> + snapext => { fixed => 1 },
> + };
> +}
> +
> +# Storage implementation
> +
> +sub parse_volname {
> + my ($class, $volname) = @_;
> +
> + PVE::Storage::Plugin::parse_lvm_name($volname);
> + my $format = $volname =~ m/^(.*)-snap-/ ? 'qcow2' : 'raw';
> +
> + if ($volname =~ m/^((vm|base)-(\d+)-\S+)$/) {
> + return ('images', $1, $3, undef, undef, $2 eq 'base', $format);
> + }
> +
> + die "unable to parse lvm volume name '$volname'\n";
> +}
> +
> +sub filesystem_path {
> + my ($class, $scfg, $volname, $snapname, $current_snap) = @_;
> +
> + my ($vtype, $name, $vmid) = $class->parse_volname($volname);
> +
> + my $vg = $scfg->{vgname};
> +
> + my $path = "/dev/$vg/$name";
> +
> + if($snapname) {
> + $path = get_snap_volname($path, $snapname);
> + } elsif ($current_snap) {
> + $path = $current_snap->{file};
> + }
> +
> + return wantarray ? ($path, $vmid, $vtype) : $path;
> +}
> +
> +sub create_base {
> + my ($class, $storeid, $scfg, $volname) = @_;
> +
> + my $vg = $scfg->{vgname};
> +
> + my ($vtype, $name, $vmid, $basename, $basevmid, $isBase) =
> + $class->parse_volname($volname);
> +
> + die "create_base not possible with base image\n" if $isBase;
> +
> + die "unable to create base volume - found snapshot" if $class->snapshot_exist($scfg, $storeid, $volname);
> +
> + my $newname = $name;
> + $newname =~ s/^vm-/base-/;
> +
> + my $cmd = ['/sbin/lvrename', $vg, $volname, $newname];
> + run_command($cmd, errmsg => "lvrename '$vg/$volname' => '$vg/$newname' error");
> +
> + # set inactive, read-only flags
> + $cmd = ['/sbin/lvchange', '-an', '-pr', "$vg/$newname"];
> + eval { run_command($cmd); };
> + warn $@ if $@;
> +
> + my $newvolname = $newname;
> +
> + return $newvolname;
> +}
> +
> +sub clone_image {
> + my ($class, $scfg, $storeid, $volname, $vmid, $snap) = @_;
> +
> + die "can't clone images in lvm storage\n";
> +}
> +
> +sub alloc_image {
> + my ($class, $storeid, $scfg, $vmid, $fmt, $name, $size) = @_;
> +
> + die "unsupported format '$fmt'" if $fmt ne 'raw';
> +
> + die "illegal name '$name' - should be 'vm-$vmid-*'\n"
> + if $name && $name !~ m/^vm-$vmid-/;
> +
> + my $vgs = PVE::Storage::LVMPlugin::lvm_vgs();
> +
> + my $vg = $scfg->{vgname};
> +
> + die "no such volume group '$vg'\n" if !defined ($vgs->{$vg});
> +
> + my $free = int($vgs->{$vg}->{free});
> +
> + die "not enough free space ($free < $size)\n" if $free < $size;
> +
> + $name = $class->find_free_diskname($storeid, $scfg, $vmid)
> + if !$name;
> +
> + my $tags = ["pve-vm-$vmid"];
> + if ($name =~ m/^(((vm|base)-(\d+)-disk-(\d+)))(-snap-(.*))?/) {
> + push @$tags, "\@pve-$1";
> + }
> +
> + PVE::Storage::LVMPlugin::lvcreate($vg, $name, $size, $tags);
> +
> + return $name;
> +}
> +
> +sub volume_snapshot_info {
> + my ($class, $scfg, $storeid, $volname) = @_;
> +
> + return $class->list_snapshots($scfg, $storeid, $volname);
> +}
> +
> +sub activate_volume {
> + my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
> +
> + my $lvm_activate_mode = 'ey';
> + my $tag = undef;
> +
> + #activate volume && all volumes snapshots by tag
> + if($volname =~ m/^(((vm|base)-(\d+)-disk-(\d+)))(-snap-(.*))?/) {
> + $tag = "\@pve-vm-$4-disk-$5";
> + }
> +
> + my $cmd = ['/sbin/lvchange', "-a$lvm_activate_mode", $tag];
> + run_command($cmd, errmsg => "can't activate LV '$tag'");
> +
> + $cmd = ['/sbin/lvchange', '--refresh', $tag];
> + run_command($cmd, errmsg => "can't refresh LV '$tag' for activation");
> +}
> +
> +sub deactivate_volume {
> + my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
> +
> + my $tag = undef;
> + #deactivate volume && all volumes snasphots by tag
> + if($volname =~ m/^(((vm|base)-(\d+)-disk-(\d+)))(-snap-(.*))?/) {
> + $tag = "\@pve-vm-$4-disk-$5";
> + }
> +
> + my $cmd = ['/sbin/lvchange', '-aln', $tag];
> + run_command($cmd, errmsg => "can't deactivate LV '$tag'");
> +}
> +
> +sub volume_resize {
> + my ($class, $scfg, $storeid, $volname, $size, $running) = @_;
> +
> + #we should resize the base image and parents snapshots,
> + #but how to manage rollback ?
> +
> + die "can't resize if snasphots exist" if $class->snapshot_exist($scfg, $storeid, $volname);
> +
> + return 1;
> +}
> +
> +sub volume_snapshot {
> + my ($class, $scfg, $storeid, $volname, $snap) = @_;
> +
> + $class->activate_volume($storeid, $scfg, $volname, undef, {});
> +
> + my $current_path = $class->path($scfg, $volname, $storeid);
> + my $current_format = (PVE::Storage::Plugin::file_size_info($current_path))[1];
> + my $snappath = get_snap_volname($current_path, $snap);
> +
> + my $snapvolname = get_snap_volname($volname, $snap);
> + #allocate lvm snapshot volume
> + my ($vtype, $name, $vmid, $basename, $basevmid, $isBase) =
> + $class->parse_volname($volname);
> + my $size = $class->volume_size_info($scfg, $storeid, $volname, 5);
> + #add 100M for qcow2 headers
> + $size = int($size/1024) + (100*1024);
> +
> + $class->alloc_image($storeid, $scfg, $vmid, 'raw', $snapvolname, $size);
> +
> + # create the qcow2 fs
> + eval {
> + my $cmd = ['/usr/bin/qemu-img', 'create', '-b', $current_path,
> + '-F', $current_format, '-f', 'qcow2', $snappath];
> + my $options = "extended_l2=on,";
> + $options .= PVE::Storage::Plugin::preallocation_cmd_option($scfg, 'qcow2');
> + push @$cmd, '-o', $options;
> + run_command($cmd);
> + };
> + if ($@) {
> + eval { $class->free_image($storeid, $scfg, $snapvolname, 0) };
> + warn $@ if $@;
> + }
> +}
> +
> +# Asserts that a rollback to $snap on $volname is possible.
> +# If certain snapshots are preventing the rollback and $blockers is an array
> +# reference, the snapshot names can be pushed onto $blockers prior to dying.
> +sub volume_rollback_is_possible {
> + my ($class, $scfg, $storeid, $volname, $snap, $blockers) = @_;
> +
> + my $path = $class->filesystem_path($scfg, $volname);
> + my $snappath = get_snap_volname($path, $snap);
> + my $currentpath = $class->path($scfg, $volname, $storeid);
> + return 1 if $currentpath eq $snappath;
> +
> + die "can't rollback, '$snap' is not most recent snapshot on '$volname'\n";
> +
> + return 1;
> +}
> +
> +sub volume_snapshot_rollback {
> + my ($class, $scfg, $storeid, $volname, $snap) = @_;
> +
> + $class->activate_volume($storeid, $scfg, $volname, undef, {});
> + #simply delete the current snapshot and recreate it
> +
> + my $snapvolname = get_snap_volname($volname, $snap);
> +
> + $class->free_image($storeid, $scfg, $snapvolname, 0);
> + $class->volume_snapshot($scfg, $storeid, $volname, $snap);
> +}
> +
> +sub volume_snapshot_delete {
> + my ($class, $scfg, $storeid, $volname, $snap, $running) = @_;
> +
> + return 1 if $running;
> +
> + $class->activate_volume($storeid, $scfg, $volname, undef, {});
> +
> + my $snapshots = $class->volume_snapshot_info($scfg, $storeid, $volname);
> + my $snappath = $snapshots->{$snap}->{file};
> + if(!$snappath) {
> + warn "$snap already deleted. skip\n";
> + return;
> + }
> +
> + my $snapvolname = $snapshots->{$snap}->{volname};
> + my $parentsnap = $snapshots->{$snap}->{parent};
> + my $childsnap = $snapshots->{$snap}->{child};
> + die "error: can't find a parent for this snapshot" if !$parentsnap;
> +
> + my $parentpath = $snapshots->{$parentsnap}->{file};
> + my $parentformat = $snapshots->{$parentsnap}->{'format'} if $parentsnap;
> + my $childpath = $snapshots->{$childsnap}->{file} if $childsnap;
> + my $childformat = $snapshots->{$childsnap}->{'format'} if $childsnap;
> +
> + print "merge snapshot $snap to $parentsnap\n";
> + my $cmd = ['/usr/bin/qemu-img', 'commit', $snappath];
> + run_command($cmd);
> +
> + #if we delete an intermediate snapshot, we need to link upper snapshot to base snapshot
> + if($childpath && -e $childpath) {
> + die "missing parentsnap snapshot to rebase child $childpath\n" if !$parentpath;
> + print "link $childsnap to $parentsnap\n";
> + $cmd = ['/usr/bin/qemu-img', 'rebase', '-u', '-b', $parentpath, '-F', $parentformat, '-f', $childformat, $childpath];
> + run_command($cmd);
> + }
> +
> + #delete the snapshot
> + $class->free_image($storeid, $scfg, $snapvolname, 0);
> +
> + return;
> +}
> +
> +sub volume_has_feature {
> + my ($class, $scfg, $feature, $storeid, $volname, $snapname, $running) = @_;
> +
> + my $features = {
> + snapshot => { current => 1 },
> +# clone => { base => 1, snap => 1}, #don't allow to clone as we can't activate the base between different host ?
> + template => { current => 1},
> + copy => { base => 1, current => 1, snap => 1},
> + sparseinit => { base => 1, current => 1},
> + rename => {current => 1},
> + };
> +
> + my ($vtype, $name, $vmid, $basename, $basevmid, $isBase) =
> + $class->parse_volname($volname);
> +
> + my $key = undef;
> + if($snapname){
> + $key = 'snap';
> + }else{
> + $key = $isBase ? 'base' : 'current';
> + }
> + return 1 if $features->{$feature}->{$key};
> +
> + return undef;
> +}
> +
> +sub get_snap_volname {
> + my ($path, $snap) = @_;
> +
> + my $basepath = "";
> + my $baseformat = "";
> + if ($path =~ m/^((.*)((vm|base)-(\d+)-disk-(\d+)))(-snap-([a-zA-Z0-9]+))?(\.(raw|qcow2))?/) {
> + $basepath = $1;
> + $baseformat = $8;
> + }
> + my $snapvolname = $basepath."-snap-$snap.qcow2";
> + return $snapvolname;
> +}
> +
> +sub get_snapname_from_path {
> + my ($path) = @_;
> +
> + if ($path =~ m/^((.*)((vm|base)-(\d+)-disk-(\d+)))(-snap-([a-zA-Z0-9]+))?(\.(raw|qcow2))?/) {
> + my $snapname = $7;
> + return $snapname;
> + }
> + die "can't parse snapname from path $path";
> +}
> +
> +sub get_current_snapshot {
> + my ($class, $scfg, $storeid, $volname) = @_;
> +
> + #get more recent ctime volume
> + return $class->list_snapshots($scfg, $storeid, $volname, 1);
> +}
> +my $check_tags = sub {
> + my ($tags) = @_;
> +
> + return defined($tags) && $tags =~ /(^|,)pve-vm-\d+(,|$)/;
> +};
> +
> +sub list_images {
> + my ($class, $storeid, $scfg, $vmid, $vollist, $cache) = @_;
> +
> + my $vgname = $scfg->{vgname};
> +
> + $cache->{lvs} = PVE::Storage::LVMPlugin::lvm_list_volumes() if !$cache->{lvs};
> +
> + my $res = [];
> +
> + if (my $dat = $cache->{lvs}->{$vgname}) {
> +
> + foreach my $volname (keys %$dat) {
> +
> + next if $volname !~ m/^(vm|base)-(\d+)-/;
> + my $owner = $2;
> +
> + my $info = $dat->{$volname};
> +
> + next if $scfg->{tagged_only} && !&$check_tags($info->{tags});
> +
> + # Allow mirrored and RAID LVs
> + next if $info->{lv_type} !~ m/^[-mMrR]$/;
> +
> + my $volid = "$storeid:$volname";
> +
> + if ($vollist) {
> + my $found = grep { $_ eq $volid } @$vollist;
> + next if !$found;
> + } else {
> + next if defined($vmid) && ($owner ne $vmid);
> + }
> +
> + push @$res, {
> + volid => $volid, format => 'raw', size => $info->{lv_size}, vmid => $owner,
> + ctime => $info->{ctime},
> + };
> + }
> + }
> +
> + return $res;
> +}
> +
> +sub list_snapshots {
> + my ($class, $scfg, $storeid, $volname, $current_only) = @_;
> +
> + my $vgname = $scfg->{vgname};
> +
> + my $basevolname = $volname;
> + my $lvs = PVE::Storage::LVMPlugin::lvm_list_volumes($vgname);
> +
> + my $vg = $lvs->{$vgname};
> +
> + my ($vtype, $name, $vmid, $basename, $basevmid, $isBase, $format) = $class->parse_volname($volname);
> + my $snapshots = $class->list_images($storeid, $scfg, $vmid);
> +
> + my $info = {};
> + for my $snap (@$snapshots) {
> + my $snap_volid = $snap->{volid};
> + next if ($snap_volid !~ m/$basevolname/);
> +
> + my $snapname = get_snapname_from_path($snap_volid);
> + my (undef, $snap_volname) = PVE::Storage::parse_volume_id($snap_volid);
> + my $snapfile = $class->filesystem_path($scfg, $snap_volname, $snapname);
> + $snapname = 'base' if !$snapname;
> + $info->{$snapname}->{file} = $snapfile;
> + $info->{$snapname}->{volname} = $snap_volname;
> + $info->{$snapname}->{volid} = $snap_volid;
> + $info->{$snapname}->{ctime} = $snap->{ctime};
> +
> + if (!$current_only) {
> + my (undef, $format, undef, $parentfile, undef) = PVE::Storage::Plugin::file_size_info($snapfile);
> + next if !$parentfile && $snapname ne 'base'; #bad unlinked snasphot
> +
> + my $parentname = get_snapname_from_path($parentfile) if $parentfile;
> + $parentname = 'base' if !$parentname && $parentfile;
> +
> + $info->{$snapname}->{'format'} = $format;
> + $info->{$snapname}->{parent} = $parentname if $parentname;
> + $info->{$parentname}->{child} = $snapname if $parentname;
> + }
> + }
> +
> + my @snapshots_sorted = sort { $info->{$b}{ctime} <=> $info->{$a}{ctime} } keys %$info;
> + my $current_snapname = $snapshots_sorted[0];
> + my $current_snapshot = $info->{$current_snapname};
> + return $current_snapshot if $current_only;
> +
> + $info->{current} = { %$current_snapshot };
> + return $info;
> +}
> +
> +sub snapshot_exist {
> + my ($class, $scfg, $storeid, $volname) = @_;
> +
> + my $basepath = $class->filesystem_path($scfg, $volname);
> + my $currentpath = $class->path($scfg, $volname, $storeid);
> +
> + die "can't resize if snasphots exist" if $currentpath ne $basepath;
> +
> +}
> +1;
> diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
> index d5cc942..1af8aab 100644
> --- a/src/PVE/Storage/Makefile
> +++ b/src/PVE/Storage/Makefile
> @@ -14,7 +14,8 @@ SOURCES= \
> PBSPlugin.pm \
> BTRFSPlugin.pm \
> LvmThinPlugin.pm \
> - ESXiPlugin.pm
> + ESXiPlugin.pm \
> + LvmQcow2Plugin.pm
>
> .PHONY: install
> install:
> --
> 2.39.2
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
next prev parent reply other threads:[~2024-10-23 10:13 UTC|newest]
Thread overview: 27+ messages / expand[flat|nested] mbox.gz Atom feed top
[not found] <20240930113153.2896648-1-alexandre.derumier@groupe-cyllene.com>
2024-09-30 11:31 ` [pve-devel] [PATCH v2 pve-storage 1/2] add external snasphot support Alexandre Derumier via pve-devel
2024-10-23 10:12 ` Fabian Grünbichler
2024-10-23 12:59 ` DERUMIER, Alexandre via pve-devel
[not found] ` <f066c13a25b30e3107a9dec8091b456ce2852293.camel@groupe-cyllene.com>
2024-10-24 6:42 ` Fabian Grünbichler
2024-10-24 7:59 ` Giotta Simon RUAGH via pve-devel
2024-10-24 9:48 ` Fabian Grünbichler
2024-10-25 20:04 ` DERUMIER, Alexandre via pve-devel
[not found] ` <7974c74b2d3a85086e8eda76e52d7a2c58d1dcb9.camel@groupe-cyllene.com>
2024-10-28 11:12 ` Fabian Grünbichler
2024-10-25 5:52 ` DERUMIER, Alexandre via pve-devel
2024-10-24 7:50 ` Fabian Grünbichler
2024-09-30 11:31 ` [pve-devel] [PATCH v2 qemu-server 1/1] implement external snapshot Alexandre Derumier via pve-devel
2024-10-23 10:14 ` Fabian Grünbichler
2024-10-23 14:31 ` DERUMIER, Alexandre via pve-devel
2024-10-23 18:09 ` DERUMIER, Alexandre via pve-devel
[not found] ` <aeb9b8ea34826483eabe7fec5e2c12b1e22e132f.camel@groupe-cyllene.com>
2024-10-24 7:43 ` Fabian Grünbichler
2024-09-30 11:31 ` [pve-devel] [PATCH v2 pve-storage 2/2] add lvmqcow2 plugin: (lvm with external qcow2 snapshot) Alexandre Derumier via pve-devel
2024-10-23 10:13 ` Fabian Grünbichler [this message]
2024-10-23 13:45 ` DERUMIER, Alexandre via pve-devel
[not found] ` <e976104d8ed7c365d8a482fa320a0691456e69c1.camel@groupe-cyllene.com>
2024-10-24 7:42 ` Fabian Grünbichler
2024-10-24 11:01 ` DERUMIER, Alexandre via pve-devel
2024-10-20 13:03 ` [pve-devel] [PATCH SERIES v2 pve-storage/qemu-server] add external qcow2 snapshot support DERUMIER, Alexandre via pve-devel
2024-10-20 17:34 ` Roland privat via pve-devel
2024-10-20 19:08 ` Esi Y via pve-devel
[not found] ` <CABtLnHqZVhDKnog6jaUBP4HcSwfanyEzWeLdUXnzJs2esJQQkA@mail.gmail.com>
2024-10-22 6:39 ` Thomas Lamprecht
2024-10-22 9:51 ` Esi Y via pve-devel
2024-10-22 14:54 ` DERUMIER, Alexandre via pve-devel
[not found] ` <2f07646b51c85ffe01089c2481dbb9680d75cfcb.camel@groupe-cyllene.com>
2024-10-24 3:37 ` Esi Y via pve-devel
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=723920338.717.1729678408238@webmail.proxmox.com \
--to=f.gruenbichler@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox