Browse Source

Add branch overiew page (#2108)

* Add branch overiew page

* fix changed method name on sub menu

* remove unused code
Bwko 1 year ago
parent
commit
3ab580c8d6

+ 79 - 0
integrations/branches_test.go

@@ -0,0 +1,79 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package integrations
6
+
7
+import (
8
+	"net/http"
9
+	"net/url"
10
+	"testing"
11
+
12
+	"github.com/PuerkitoBio/goquery"
13
+	"github.com/Unknwon/i18n"
14
+	"github.com/stretchr/testify/assert"
15
+)
16
+
17
+func TestViewBranches(t *testing.T) {
18
+	prepareTestEnv(t)
19
+
20
+	req := NewRequest(t, "GET", "/user2/repo1/branches")
21
+	resp := MakeRequest(t, req, http.StatusOK)
22
+
23
+	htmlDoc := NewHTMLParser(t, resp.Body)
24
+	_, exists := htmlDoc.doc.Find(".delete-branch-button").Attr("data-url")
25
+	assert.False(t, exists, "The template has changed")
26
+}
27
+
28
+func TestDeleteBranch(t *testing.T) {
29
+	prepareTestEnv(t)
30
+
31
+	deleteBranch(t)
32
+}
33
+
34
+func TestUndoDeleteBranch(t *testing.T) {
35
+	prepareTestEnv(t)
36
+
37
+	deleteBranch(t)
38
+	htmlDoc, name := branchAction(t, ".undo-button")
39
+	assert.Contains(t,
40
+		htmlDoc.doc.Find(".ui.positive.message").Text(),
41
+		i18n.Tr("en", "repo.branch.restore_success", name),
42
+	)
43
+}
44
+
45
+func deleteBranch(t *testing.T) {
46
+	htmlDoc, name := branchAction(t, ".delete-branch-button")
47
+	assert.Contains(t,
48
+		htmlDoc.doc.Find(".ui.positive.message").Text(),
49
+		i18n.Tr("en", "repo.branch.deletion_success", name),
50
+	)
51
+}
52
+
53
+func branchAction(t *testing.T, button string) (*HTMLDoc, string) {
54
+	session := loginUser(t, "user2")
55
+	req := NewRequest(t, "GET", "/user2/repo1/branches")
56
+	resp := session.MakeRequest(t, req, http.StatusOK)
57
+
58
+	htmlDoc := NewHTMLParser(t, resp.Body)
59
+	link, exists := htmlDoc.doc.Find(button).Attr("data-url")
60
+	assert.True(t, exists, "The template has changed")
61
+
62
+	htmlDoc = NewHTMLParser(t, resp.Body)
63
+	req = NewRequestWithValues(t, "POST", link, map[string]string{
64
+		"_csrf": getCsrf(htmlDoc.doc),
65
+	})
66
+	resp = session.MakeRequest(t, req, http.StatusOK)
67
+
68
+	url, err := url.Parse(link)
69
+	assert.NoError(t, err)
70
+	req = NewRequest(t, "GET", "/user2/repo1/branches")
71
+	resp = session.MakeRequest(t, req, http.StatusOK)
72
+
73
+	return NewHTMLParser(t, resp.Body), url.Query()["name"][0]
74
+}
75
+
76
+func getCsrf(doc *goquery.Document) string {
77
+	csrf, _ := doc.Find("meta[name=\"_csrf\"]").Attr("content")
78
+	return csrf
79
+}

+ 107 - 0
models/branches.go

@@ -11,6 +11,7 @@ import (
11 11
 
12 12
 	"code.gitea.io/gitea/modules/base"
13 13
 	"code.gitea.io/gitea/modules/log"
14
+	"code.gitea.io/gitea/modules/setting"
14 15
 	"code.gitea.io/gitea/modules/util"
15 16
 
16 17
 	"github.com/Unknwon/com"
@@ -193,3 +194,109 @@ func (repo *Repository) DeleteProtectedBranch(id int64) (err error) {
193 194
 
194 195
 	return sess.Commit()
195 196
 }
197
+
198
+// DeletedBranch struct
199
+type DeletedBranch struct {
200
+	ID          int64     `xorm:"pk autoincr"`
201
+	RepoID      int64     `xorm:"UNIQUE(s) INDEX NOT NULL"`
202
+	Name        string    `xorm:"UNIQUE(s) NOT NULL"`
203
+	Commit      string    `xorm:"UNIQUE(s) NOT NULL"`
204
+	DeletedByID int64     `xorm:"INDEX"`
205
+	DeletedBy   *User     `xorm:"-"`
206
+	Deleted     time.Time `xorm:"-"`
207
+	DeletedUnix int64     `xorm:"INDEX created"`
208
+}
209
+
210
+// AfterLoad is invoked from XORM after setting the values of all fields of this object.
211
+func (deletedBranch *DeletedBranch) AfterLoad() {
212
+	deletedBranch.Deleted = time.Unix(deletedBranch.DeletedUnix, 0).Local()
213
+}
214
+
215
+// AddDeletedBranch adds a deleted branch to the database
216
+func (repo *Repository) AddDeletedBranch(branchName, commit string, deletedByID int64) error {
217
+	deletedBranch := &DeletedBranch{
218
+		RepoID:      repo.ID,
219
+		Name:        branchName,
220
+		Commit:      commit,
221
+		DeletedByID: deletedByID,
222
+	}
223
+
224
+	sess := x.NewSession()
225
+	defer sess.Close()
226
+	if err := sess.Begin(); err != nil {
227
+		return err
228
+	}
229
+
230
+	if _, err := sess.InsertOne(deletedBranch); err != nil {
231
+		return err
232
+	}
233
+
234
+	return sess.Commit()
235
+}
236
+
237
+// GetDeletedBranches returns all the deleted branches
238
+func (repo *Repository) GetDeletedBranches() ([]*DeletedBranch, error) {
239
+	deletedBranches := make([]*DeletedBranch, 0)
240
+	return deletedBranches, x.Where("repo_id = ?", repo.ID).Desc("deleted_unix").Find(&deletedBranches)
241
+}
242
+
243
+// GetDeletedBranchByID get a deleted branch by its ID
244
+func (repo *Repository) GetDeletedBranchByID(ID int64) (*DeletedBranch, error) {
245
+	deletedBranch := &DeletedBranch{ID: ID}
246
+	has, err := x.Get(deletedBranch)
247
+	if err != nil {
248
+		return nil, err
249
+	}
250
+	if !has {
251
+		return nil, nil
252
+	}
253
+	return deletedBranch, nil
254
+}
255
+
256
+// RemoveDeletedBranch removes a deleted branch from the database
257
+func (repo *Repository) RemoveDeletedBranch(id int64) (err error) {
258
+	deletedBranch := &DeletedBranch{
259
+		RepoID: repo.ID,
260
+		ID:     id,
261
+	}
262
+
263
+	sess := x.NewSession()
264
+	defer sess.Close()
265
+	if err = sess.Begin(); err != nil {
266
+		return err
267
+	}
268
+
269
+	if affected, err := sess.Delete(deletedBranch); err != nil {
270
+		return err
271
+	} else if affected != 1 {
272
+		return fmt.Errorf("remove deleted branch ID(%v) failed", id)
273
+	}
274
+
275
+	return sess.Commit()
276
+}
277
+
278
+// LoadUser loads the user that deleted the branch
279
+// When there's no user found it returns a NewGhostUser
280
+func (deletedBranch *DeletedBranch) LoadUser() {
281
+	user, err := GetUserByID(deletedBranch.DeletedByID)
282
+	if err != nil {
283
+		user = NewGhostUser()
284
+	}
285
+	deletedBranch.DeletedBy = user
286
+}
287
+
288
+// RemoveOldDeletedBranches removes old deleted branches
289
+func RemoveOldDeletedBranches() {
290
+	if !taskStatusTable.StartIfNotRunning(`deleted_branches_cleanup`) {
291
+		return
292
+	}
293
+	defer taskStatusTable.Stop(`deleted_branches_cleanup`)
294
+
295
+	log.Trace("Doing: DeletedBranchesCleanup")
296
+
297
+	deleteBefore := time.Now().Add(-setting.Cron.DeletedBranchesCleanup.OlderThan)
298
+	_, err := x.Where("deleted_unix < ?", deleteBefore.Unix()).Delete(new(DeletedBranch))
299
+	if err != nil {
300
+		log.Error(4, "DeletedBranchesCleanup: %v", err)
301
+	}
302
+}

+ 89 - 0
models/branches_test.go

@@ -0,0 +1,89 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package models
6
+
7
+import (
8
+	"testing"
9
+
10
+	"github.com/stretchr/testify/assert"
11
+)
12
+
13
+var firstBranch = DeletedBranch{
14
+	ID:          1,
15
+	Name:        "foo",
16
+	Commit:      "1213212312313213213132131",
17
+	DeletedByID: int64(1),
18
+}
19
+
20
+var secondBranch = DeletedBranch{
21
+	ID:          2,
22
+	Name:        "bar",
23
+	Commit:      "5655464564554545466464655",
24
+	DeletedByID: int64(99),
25
+}
26
+
27
+func TestAddDeletedBranch(t *testing.T) {
28
+	assert.NoError(t, PrepareTestDatabase())
29
+	repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
30
+	assert.NoError(t, repo.AddDeletedBranch(firstBranch.Name, firstBranch.Commit, firstBranch.DeletedByID))
31
+	assert.Error(t, repo.AddDeletedBranch(firstBranch.Name, firstBranch.Commit, firstBranch.DeletedByID))
32
+	assert.NoError(t, repo.AddDeletedBranch(secondBranch.Name, secondBranch.Commit, secondBranch.DeletedByID))
33
+}
34
+func TestGetDeletedBranches(t *testing.T) {
35
+	assert.NoError(t, PrepareTestDatabase())
36
+	AssertExistsAndLoadBean(t, &DeletedBranch{ID: 1})
37
+	repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
38
+
39
+	branches, err := repo.GetDeletedBranches()
40
+	assert.NoError(t, err)
41
+	assert.Len(t, branches, 2)
42
+}
43
+
44
+func TestGetDeletedBranch(t *testing.T) {
45
+	assert.NoError(t, PrepareTestDatabase())
46
+	assert.NotNil(t, getDeletedBranch(t, firstBranch))
47
+}
48
+
49
+func TestDeletedBranchLoadUser(t *testing.T) {
50
+	assert.NoError(t, PrepareTestDatabase())
51
+	branch := getDeletedBranch(t, firstBranch)
52
+	assert.Nil(t, branch.DeletedBy)
53
+	branch.LoadUser()
54
+	assert.NotNil(t, branch.DeletedBy)
55
+	assert.Equal(t, "user1", branch.DeletedBy.Name)
56
+
57
+	branch = getDeletedBranch(t, secondBranch)
58
+	assert.Nil(t, branch.DeletedBy)
59
+	branch.LoadUser()
60
+	assert.NotNil(t, branch.DeletedBy)
61
+	assert.Equal(t, "Ghost", branch.DeletedBy.Name)
62
+}
63
+
64
+func TestRemoveDeletedBranch(t *testing.T) {
65
+	assert.NoError(t, PrepareTestDatabase())
66
+
67
+	branch := DeletedBranch{ID: 1}
68
+	AssertExistsAndLoadBean(t, &branch)
69
+	repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
70
+
71
+	err := repo.RemoveDeletedBranch(1)
72
+	assert.NoError(t, err)
73
+	AssertNotExistsBean(t, &branch)
74
+	AssertExistsAndLoadBean(t, &DeletedBranch{ID: 2})
75
+}
76
+
77
+func getDeletedBranch(t *testing.T, branch DeletedBranch) *DeletedBranch {
78
+	AssertExistsAndLoadBean(t, &DeletedBranch{ID: 1})
79
+	repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
80
+
81
+	deletedBranch, err := repo.GetDeletedBranchByID(branch.ID)
82
+	assert.NoError(t, err)
83
+	assert.Equal(t, branch.ID, deletedBranch.ID)
84
+	assert.Equal(t, branch.Name, deletedBranch.Name)
85
+	assert.Equal(t, branch.Commit, deletedBranch.Commit)
86
+	assert.Equal(t, branch.DeletedByID, deletedBranch.DeletedByID)
87
+
88
+	return deletedBranch
89
+}

+ 2 - 0
models/migrations/migrations.go

@@ -142,6 +142,8 @@ var migrations = []Migration{
142 142
 	NewMigration("remove index column from repo_unit table", removeIndexColumnFromRepoUnitTable),
143 143
 	// v46 -> v47
144 144
 	NewMigration("remove organization watch repositories", removeOrganizationWatchRepo),
145
+	// v47 -> v48
146
+	NewMigration("add deleted branches", addDeletedBranch),
145 147
 }
146 148
 
147 149
 // Migrate database to current version

+ 29 - 0
models/migrations/v47.go

@@ -0,0 +1,29 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package migrations
6
+
7
+import (
8
+	"fmt"
9
+
10
+	"github.com/go-xorm/xorm"
11
+)
12
+
13
+func addDeletedBranch(x *xorm.Engine) (err error) {
14
+	// DeletedBranch contains the deleted branch information
15
+	type DeletedBranch struct {
16
+		ID          int64  `xorm:"pk autoincr"`
17
+		RepoID      int64  `xorm:"UNIQUE(s) INDEX NOT NULL"`
18
+		Name        string `xorm:"UNIQUE(s) NOT NULL"`
19
+		Commit      string `xorm:"UNIQUE(s) NOT NULL"`
20
+		DeletedByID int64  `xorm:"INDEX NOT NULL"`
21
+		DeletedUnix int64  `xorm:"INDEX"`
22
+	}
23
+
24
+	if err = x.Sync2(new(DeletedBranch)); err != nil {
25
+		return fmt.Errorf("Sync2: %v", err)
26
+	}
27
+
28
+	return nil
29
+}

+ 1 - 0
models/models.go

@@ -114,6 +114,7 @@ func init() {
114 114
 		new(CommitStatus),
115 115
 		new(Stopwatch),
116 116
 		new(TrackedTime),
117
+		new(DeletedBranch),
117 118
 	)
118 119
 
119 120
 	gonicNames := []string{"SSL", "UID"}

+ 11 - 0
modules/cron/cron.go

@@ -77,6 +77,17 @@ func NewContext() {
77 77
 			go models.SyncExternalUsers()
78 78
 		}
79 79
 	}
80
+	if setting.Cron.DeletedBranchesCleanup.Enabled {
81
+		entry, err = c.AddFunc("Remove old deleted branches", setting.Cron.DeletedBranchesCleanup.Schedule, models.RemoveOldDeletedBranches)
82
+		if err != nil {
83
+			log.Fatal(4, "Cron[Remove old deleted branches]: %v", err)
84
+		}
85
+		if setting.Cron.DeletedBranchesCleanup.RunAtStart {
86
+			entry.Prev = time.Now()
87
+			entry.ExecTimes++
88
+			go models.RemoveOldDeletedBranches()
89
+		}
90
+	}
80 91
 	c.Start()
81 92
 }
82 93
 

+ 17 - 0
modules/setting/setting.go

@@ -365,6 +365,12 @@ var (
365 365
 			Schedule       string
366 366
 			UpdateExisting bool
367 367
 		} `ini:"cron.sync_external_users"`
368
+		DeletedBranchesCleanup struct {
369
+			Enabled    bool
370
+			RunAtStart bool
371
+			Schedule   string
372
+			OlderThan  time.Duration
373
+		} `ini:"cron.deleted_branches_cleanup"`
368 374
 	}{
369 375
 		UpdateMirror: struct {
370 376
 			Enabled    bool
@@ -419,6 +425,17 @@ var (
419 425
 			Schedule:       "@every 24h",
420 426
 			UpdateExisting: true,
421 427
 		},
428
+		DeletedBranchesCleanup: struct {
429
+			Enabled    bool
430
+			RunAtStart bool
431
+			Schedule   string
432
+			OlderThan  time.Duration
433
+		}{
434
+			Enabled:    true,
435
+			RunAtStart: true,
436
+			Schedule:   "@every 24h",
437
+			OlderThan:  24 * time.Hour,
438
+		},
422 439
 	}
423 440
 
424 441
 	// Git settings

+ 10 - 0
options/locale/locale_en-US.ini

@@ -1055,10 +1055,16 @@ release.tag_name_already_exist = Release with this tag name already exists.
1055 1055
 release.tag_name_invalid = Tag name is not valid.
1056 1056
 release.downloads = Downloads
1057 1057
 
1058
+branch.name = Branch name
1059
+branch.search = Search branches
1060
+branch.already_exists = A branch named %s already exists.
1061
+branch.delete_head = Delete
1058 1062
 branch.delete = Delete Branch %s
1063
+branch.delete_html = Delete Branch
1059 1064
 branch.delete_desc = Deleting a branch is permanent. There is no way to undo it.
1060 1065
 branch.delete_notices_1 = - This operation <strong>CANNOT</strong> be undone.
1061 1066
 branch.delete_notices_2 = - This operation will permanently delete everything in branch %s.
1067
+branch.delete_notices_html = - This operation will permanently delete everything in branch
1062 1068
 branch.deletion_success = %s has been deleted.
1063 1069
 branch.deletion_failed = Failed to delete branch %s.
1064 1070
 branch.delete_branch_has_new_commits = %s cannot be deleted because new commits have been added after merging.
@@ -1068,6 +1074,10 @@ branch.create_success = Branch '%s' has been created successfully!
1068 1074
 branch.branch_already_exists = Branch '%s' already exists in this repository.
1069 1075
 branch.branch_name_conflict = Branch name '%s' conflicts with already existing branch '%s'.
1070 1076
 branch.tag_collision = Branch '%s' can not be created as tag with same name already exists in this repository.
1077
+branch.deleted_by = Deleted by %s
1078
+branch.restore_success = %s successfully restored
1079
+branch.restore_failed = Failed to restore branch %s.
1080
+branch.protected_deletion_failed = It's not possible to delete protected branch %s.
1071 1081
 
1072 1082
 [org]
1073 1083
 org_name_holder = Organization Name

File diff suppressed because it is too large
+ 1 - 1
public/css/index.css


+ 37 - 22
public/js/index.js

@@ -1423,29 +1423,18 @@ $(document).ready(function () {
1423 1423
     });
1424 1424
 
1425 1425
     // Helpers.
1426
-    $('.delete-button').click(function () {
1427
-        var $this = $(this);
1428
-        var filter = "";
1429
-        if ($this.attr("id")) {
1430
-          filter += "#"+$this.attr("id")
1431
-        }
1432
-        $('.delete.modal'+filter).modal({
1433
-            closable: false,
1434
-            onApprove: function () {
1435
-                if ($this.data('type') == "form") {
1436
-                    $($this.data('form')).submit();
1437
-                    return;
1438
-                }
1426
+    $('.delete-button').click(showDeletePopup);
1439 1427
 
1440
-                $.post($this.data('url'), {
1441
-                    "_csrf": csrf,
1442
-                    "id": $this.data("id")
1443
-                }).done(function (data) {
1444
-                    window.location.href = data.redirect;
1445
-                });
1446
-            }
1447
-        }).modal('show');
1448
-        return false;
1428
+    $('.delete-branch-button').click(showDeletePopup);
1429
+
1430
+    $('.undo-button').click(function() {
1431
+        var $this = $(this);
1432
+        $.post($this.data('url'), {
1433
+            "_csrf": csrf,
1434
+            "id": $this.data("id")
1435
+        }).done(function(data) {
1436
+            window.location.href = data.redirect;
1437
+        });
1449 1438
     });
1450 1439
     $('.show-panel.button').click(function () {
1451 1440
         $($(this).data('panel')).show();
@@ -1608,6 +1597,32 @@ $(function () {
1608 1597
     });
1609 1598
 });
1610 1599
 
1600
+function showDeletePopup() {
1601
+    var $this = $(this);
1602
+    var filter = "";
1603
+    if ($this.attr("id")) {
1604
+        filter += "#" + $this.attr("id")
1605
+    }
1606
+
1607
+    $('.delete.modal' + filter).modal({
1608
+        closable: false,
1609
+        onApprove: function() {
1610
+            if ($this.data('type') == "form") {
1611
+                $($this.data('form')).submit();
1612
+                return;
1613
+            }
1614
+
1615
+            $.post($this.data('url'), {
1616
+                "_csrf": csrf,
1617
+                "id": $this.data("id")
1618
+            }).done(function(data) {
1619
+                window.location.href = data.redirect;
1620
+            });
1621
+        }
1622
+    }).modal('show');
1623
+    return false;
1624
+}
1625
+
1611 1626
 function initVueComponents(){
1612 1627
     var vueDelimeters = ['${', '}'];
1613 1628
 

+ 9 - 2
public/less/_explore.less

@@ -9,7 +9,7 @@
9 9
         margin-bottom: 15px !important;
10 10
         background-color: #FAFAFA !important;
11 11
         border-width: 1px !important;
12
-        
12
+
13 13
 		.octicon {
14 14
 			width: 16px;
15 15
 			text-align: center;
@@ -33,7 +33,7 @@
33 33
 			.name {
34 34
 				word-break: break-all;
35 35
 			}
36
-			
36
+
37 37
 			.metas {
38 38
 				color: #888;
39 39
 				font-size: 14px;
@@ -50,6 +50,13 @@
50 50
 	}
51 51
 }
52 52
 
53
+.ui.repository.branches  {
54
+	.time{
55
+		font-size: 12px;
56
+		color: #808080;
57
+	}
58
+}
59
+
53 60
 .ui.user.list {
54 61
 	.item {
55 62
 		padding-bottom: 25px;

+ 21 - 0
public/less/_repository.less

@@ -1313,6 +1313,27 @@
1313 1313
             border-bottom: 1px solid #A3C293;
1314 1314
         }
1315 1315
 	}
1316
+	.ui.segment.sub-menu {
1317
+		padding: 7px;
1318
+		line-height: 0;
1319
+		.list {
1320
+			width: 100%;
1321
+			display: flex;
1322
+			.item {
1323
+				width:100%;
1324
+				border-radius: 3px;
1325
+				a {
1326
+					color: black;
1327
+					&:hover {
1328
+						color: #666;
1329
+					}
1330
+				}
1331
+				&.active {
1332
+					background: rgba(0,0,0,.05);;
1333
+				}
1334
+			}
1335
+		}
1336
+	}
1316 1337
 }
1317 1338
 // End of .repository
1318 1339
 

+ 167 - 7
routers/repo/branch.go

@@ -5,32 +5,192 @@
5 5
 package repo
6 6
 
7 7
 import (
8
+	"strings"
9
+
10
+	"code.gitea.io/git"
8 11
 	"code.gitea.io/gitea/models"
9 12
 	"code.gitea.io/gitea/modules/auth"
10 13
 	"code.gitea.io/gitea/modules/base"
11 14
 	"code.gitea.io/gitea/modules/context"
15
+	"code.gitea.io/gitea/modules/log"
12 16
 )
13 17
 
14 18
 const (
15
-	tplBranch base.TplName = "repo/branch"
19
+	tplBranch base.TplName = "repo/branch/list"
16 20
 )
17 21
 
22
+// Branch contains the branch information
23
+type Branch struct {
24
+	Name          string
25
+	Commit        *git.Commit
26
+	IsProtected   bool
27
+	IsDeleted     bool
28
+	DeletedBranch *models.DeletedBranch
29
+}
30
+
18 31
 // Branches render repository branch page
19 32
 func Branches(ctx *context.Context) {
20 33
 	ctx.Data["Title"] = "Branches"
21 34
 	ctx.Data["IsRepoToolbarBranches"] = true
35
+	ctx.Data["DefaultBranch"] = ctx.Repo.Repository.DefaultBranch
36
+	ctx.Data["IsWriter"] = ctx.Repo.IsWriter()
37
+	ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror
38
+	ctx.Data["PageIsViewCode"] = true
39
+	ctx.Data["PageIsBranches"] = true
22 40
 
23
-	brs, err := ctx.Repo.GitRepo.GetBranches()
41
+	ctx.Data["Branches"] = loadBranches(ctx)
42
+	ctx.HTML(200, tplBranch)
43
+}
44
+
45
+// DeleteBranchPost responses for delete merged branch
46
+func DeleteBranchPost(ctx *context.Context) {
47
+	defer redirect(ctx)
48
+
49
+	branchName := ctx.Query("name")
50
+	isProtected, err := ctx.Repo.Repository.IsProtectedBranch(branchName, ctx.User)
24 51
 	if err != nil {
25
-		ctx.Handle(500, "repo.Branches(GetBranches)", err)
52
+		log.Error(4, "DeleteBranch: %v", err)
53
+		ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName))
26 54
 		return
27
-	} else if len(brs) == 0 {
28
-		ctx.Handle(404, "repo.Branches(GetBranches)", nil)
55
+	}
56
+
57
+	if isProtected {
58
+		ctx.Flash.Error(ctx.Tr("repo.branch.protected_deletion_failed", branchName))
29 59
 		return
30 60
 	}
31 61
 
32
-	ctx.Data["Branches"] = brs
33
-	ctx.HTML(200, tplBranch)
62
+	if !ctx.Repo.GitRepo.IsBranchExist(branchName) || branchName == ctx.Repo.Repository.DefaultBranch {
63
+		ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName))
64
+		return
65
+	}
66
+
67
+	if err := deleteBranch(ctx, branchName); err != nil {
68
+		ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName))
69
+		return
70
+	}
71
+
72
+	ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", branchName))
73
+}
74
+
75
+// RestoreBranchPost responses for delete merged branch
76
+func RestoreBranchPost(ctx *context.Context) {
77
+	defer redirect(ctx)
78
+
79
+	branchID := ctx.QueryInt64("branch_id")
80
+	branchName := ctx.Query("name")
81
+
82
+	deletedBranch, err := ctx.Repo.Repository.GetDeletedBranchByID(branchID)
83
+	if err != nil {
84
+		log.Error(4, "GetDeletedBranchByID: %v", err)
85
+		ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName))
86
+		return
87
+	}
88
+
89
+	if err := ctx.Repo.GitRepo.CreateBranch(deletedBranch.Name, deletedBranch.Commit); err != nil {
90
+		if strings.Contains(err.Error(), "already exists") {
91
+			ctx.Flash.Error(ctx.Tr("repo.branch.already_exists", deletedBranch.Name))
92
+			return
93
+		}
94
+		log.Error(4, "CreateBranch: %v", err)
95
+		ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", deletedBranch.Name))
96
+		return
97
+	}
98
+
99
+	if err := ctx.Repo.Repository.RemoveDeletedBranch(deletedBranch.ID); err != nil {
100
+		log.Error(4, "RemoveDeletedBranch: %v", err)
101
+		ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", deletedBranch.Name))
102
+		return
103
+	}
104
+
105
+	ctx.Flash.Success(ctx.Tr("repo.branch.restore_success", deletedBranch.Name))
106
+}
107
+
108
+func redirect(ctx *context.Context) {
109
+	ctx.JSON(200, map[string]interface{}{
110
+		"redirect": ctx.Repo.RepoLink + "/branches",
111
+	})
112
+}
113
+
114
+func deleteBranch(ctx *context.Context, branchName string) error {
115
+	commit, err := ctx.Repo.GitRepo.GetBranchCommit(branchName)
116
+	if err != nil {
117
+		log.Error(4, "GetBranchCommit: %v", err)
118
+		return err
119
+	}
120
+
121
+	if err := ctx.Repo.GitRepo.DeleteBranch(branchName, git.DeleteBranchOptions{
122
+		Force: true,
123
+	}); err != nil {
124
+		log.Error(4, "DeleteBranch: %v", err)
125
+		return err
126
+	}
127
+
128
+	// Don't return error here
129
+	if err := ctx.Repo.Repository.AddDeletedBranch(branchName, commit.ID.String(), ctx.User.ID); err != nil {
130
+		log.Warn("AddDeletedBranch: %v", err)
131
+	}
132
+
133
+	return nil
134
+}
135
+
136
+func loadBranches(ctx *context.Context) []*Branch {
137
+	rawBranches, err := ctx.Repo.Repository.GetBranches()
138
+	if err != nil {
139
+		ctx.Handle(500, "GetBranches", err)
140
+		return nil
141
+	}
142
+
143
+	branches := make([]*Branch, len(rawBranches))
144
+	for i := range rawBranches {
145
+		commit, err := rawBranches[i].GetCommit()
146
+		if err != nil {
147
+			ctx.Handle(500, "GetCommit", err)
148
+			return nil
149
+		}
150
+
151
+		isProtected, err := ctx.Repo.Repository.IsProtectedBranch(rawBranches[i].Name, ctx.User)
152
+		if err != nil {
153
+			ctx.Handle(500, "IsProtectedBranch", err)
154
+			return nil
155
+		}
156
+
157
+		branches[i] = &Branch{
158
+			Name:        rawBranches[i].Name,
159
+			Commit:      commit,
160
+			IsProtected: isProtected,
161
+		}
162
+	}
163
+
164
+	if ctx.Repo.IsWriter() {
165
+		deletedBranches, err := getDeletedBranches(ctx)
166
+		if err != nil {
167
+			ctx.Handle(500, "getDeletedBranches", err)
168
+			return nil
169
+		}
170
+		branches = append(branches, deletedBranches...)
171
+	}
172
+
173
+	return branches
174
+}
175
+
176
+func getDeletedBranches(ctx *context.Context) ([]*Branch, error) {
177
+	branches := []*Branch{}
178
+
179
+	deletedBranches, err := ctx.Repo.Repository.GetDeletedBranches()
180
+	if err != nil {
181
+		return branches, err
182
+	}
183
+
184
+	for i := range deletedBranches {
185
+		deletedBranches[i].LoadUser()
186
+		branches = append(branches, &Branch{
187
+			Name:          deletedBranches[i].Name,
188
+			IsDeleted:     true,
189
+			DeletedBranch: deletedBranches[i],
190
+		})
191
+	}
192
+
193
+	return branches, nil
34 194
 }
35 195
 
36 196
 // CreateBranch creates new branch in repository

+ 3 - 0
routers/repo/commit.go

@@ -53,6 +53,7 @@ func Commits(ctx *context.Context) {
53 53
 		ctx.Handle(404, "Commit not found", nil)
54 54
 		return
55 55
 	}
56
+	ctx.Data["PageIsViewCode"] = true
56 57
 
57 58
 	commitsCount, err := ctx.Repo.Commit.CommitsCount()
58 59
 	if err != nil {
@@ -88,6 +89,7 @@ func Commits(ctx *context.Context) {
88 89
 // Graph render commit graph - show commits from all branches.
89 90
 func Graph(ctx *context.Context) {
90 91
 	ctx.Data["PageIsCommits"] = true
92
+	ctx.Data["PageIsViewCode"] = true
91 93
 
92 94
 	commitsCount, err := ctx.Repo.Commit.CommitsCount()
93 95
 	if err != nil {
@@ -114,6 +116,7 @@ func Graph(ctx *context.Context) {
114 116
 // SearchCommits render commits filtered by keyword
115 117
 func SearchCommits(ctx *context.Context) {
116 118
 	ctx.Data["PageIsCommits"] = true
119
+	ctx.Data["PageIsViewCode"] = true
117 120
 
118 121
 	keyword := strings.Trim(ctx.Query("q"), " ")
119 122
 	if len(keyword) == 0 {

+ 8 - 1
routers/routes/routes.go

@@ -550,7 +550,10 @@ func RegisterRoutes(m *macaron.Macaron) {
550 550
 
551 551
 		m.Group("/branches", func() {
552 552
 			m.Post("/_new/*", context.RepoRef(), bindIgnErr(auth.NewBranchForm{}), repo.CreateBranch)
553
-		}, reqRepoWriter, repo.MustBeNotBare)
553
+			m.Post("/delete", repo.DeleteBranchPost)
554
+			m.Post("/restore", repo.RestoreBranchPost)
555
+		}, reqRepoWriter, repo.MustBeNotBare, context.CheckUnit(models.UnitTypeCode))
556
+
554 557
 	}, reqSignIn, context.RepoAssignment(), context.UnitTypes(), context.LoadRepoUnits())
555 558
 
556 559
 	// Releases
@@ -615,6 +618,10 @@ func RegisterRoutes(m *macaron.Macaron) {
615 618
 
616 619
 		m.Get("/archive/*", repo.MustBeNotBare, context.CheckUnit(models.UnitTypeCode), repo.Download)
617 620
 
621
+		m.Group("/branches", func() {
622
+			m.Get("", repo.Branches)
623
+		}, repo.MustBeNotBare, context.RepoRef(), context.CheckUnit(models.UnitTypeCode))
624
+
618 625
 		m.Group("/pulls/:index", func() {
619 626
 			m.Get("/commits", context.RepoRef(), repo.ViewPullCommits)
620 627
 			m.Get("/files", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.ViewPullFiles)

+ 81 - 0
templates/repo/branch/list.tmpl

@@ -0,0 +1,81 @@
1
+{{template "base/head" .}}
2
+<div class="ui repository branches">
3
+	{{template "repo/header" .}}
4
+	<div class="ui container">
5
+		{{template "base/alert" .}}
6
+		{{template "repo/sub_menu" .}}
7
+		<h4 class="ui top attached header">
8
+			{{.i18n.Tr "repo.default_branch"}}
9
+		</h4>
10
+
11
+		<div class="ui attached table segment">
12
+			<table class="ui very basic striped fixed table single line">
13
+				<tbody>
14
+					<tr>
15
+						<td>{{.DefaultBranch}}</td>
16
+					</tr>
17
+				</tbody>
18
+			</table>
19
+		</div>
20
+
21
+		{{if gt (len .Branches) 1}}
22
+			<h4 class="ui top attached header">
23
+				{{.i18n.Tr "repo.branches"}}
24
+			</h4>
25
+			<div class="ui attached table segment">
26
+				<table class="ui very basic striped fixed table single line">
27
+					<thead>
28
+						<tr>
29
+							<th class="nine wide">{{.i18n.Tr "repo.branch.name"}}</th>
30
+							{{if and $.IsWriter (not $.IsMirror)}}
31
+								<th class="one wide right aligned">{{.i18n.Tr "repo.branch.delete_head"}}</th>
32
+							{{end}}
33
+						</tr>
34
+					</thead>
35
+					<tbody>
36
+						{{range $branch := .Branches}}
37
+							{{if ne .Name $.DefaultBranch}}
38
+								<tr>
39
+									<td>
40
+									{{if .IsDeleted}}
41
+										<s>{{.Name}}</s>
42
+										<p class="time">{{$.i18n.Tr "repo.branch.deleted_by" .DeletedBranch.DeletedBy.Name}} {{TimeSince .DeletedBranch.Deleted $.i18n.Lang}}</p>
43
+									{{else}}
44
+										{{.Name}}
45
+										<p class="time">{{$.i18n.Tr "org.repo_updated"}} {{TimeSince .Commit.Committer.When $.i18n.Lang}}</p>
46
+									</td>
47
+									{{end}}
48
+									{{if and $.IsWriter (not $.IsMirror)}}
49
+										<td class="right aligned">
50
+										{{if .IsProtected}}
51
+											<i class="octicon octicon-shield"></i>
52
+										{{else if .IsDeleted}}
53
+											<a class="undo-button" href data-url="{{$.Link}}/restore?branch_id={{.DeletedBranch.ID | urlquery}}&name={{.DeletedBranch.Name | urlquery}}"><i class="octicon octicon-reply"></i></a>
54
+										{{else}}
55
+											<a class="delete-branch-button" href data-url="{{$.Link}}/delete?name={{.Name | urlquery}}" data-val="{{.Name}}"><i class="trash icon text red"></i></a>
56
+										{{end}}
57
+										</td>
58
+									{{end}}
59
+								</tr>
60
+							{{end}}
61
+						{{end}}
62
+					</tbody>
63
+				</table>
64
+			</div>
65
+		{{end}}
66
+	</div>
67
+</div>
68
+
69
+<div class="ui small basic delete modal">
70
+	<div class="ui icon header">
71
+		<i class="trash icon"></i>
72
+		{{.i18n.Tr "repo.branch.delete_html"| Safe}} <span class="branch-name"></span>
73
+	</div>
74
+	<div class="content">
75
+		<p>{{.i18n.Tr "repo.branch.delete_desc" | Safe}}</p>
76
+		{{.i18n.Tr "repo.branch.delete_notices_1" | Safe}}<br>
77
+		{{.i18n.Tr "repo.branch.delete_notices_html" | Safe}} <span class="branch-name"></span><br>
78
+	</div>
79
+	{{template "base/delete_modal_actions" .}}
80
+</div>
81
+{{template "base/footer" .}}

+ 13 - 12
templates/repo/commits.tmpl

@@ -2,18 +2,19 @@
2 2
 <div class="repository commits">
3 3
 	{{template "repo/header" .}}
4 4
 	<div class="ui container">
5
-	  <div class="ui secondary menu">
6
-	    {{template "repo/branch_dropdown" .}}
7
-	    <div class="fitted item">
8
-		<a href="{{.RepoLink}}/graph" class="ui basic small button">
9
-		  <span class="text">
10
-		    <i class="octicon octicon-git-branch"></i>
11
-		  </span>
12
-		  {{.i18n.Tr "repo.commit_graph"}}
13
-		</a>
14
-	    </div>
15
-	  </div>
16
-	  {{template "repo/commits_table" .}}
5
+		{{template "repo/sub_menu" .}}
6
+		<div class="ui secondary menu">
7
+		{{template "repo/branch_dropdown" .}}
8
+			<div class="fitted item">
9
+				<a href="{{.RepoLink}}/graph" class="ui basic small button">
10
+					<span class="text">
11
+						<i class="octicon octicon-git-branch"></i>
12
+					</span>
13
+					{{.i18n.Tr "repo.commit_graph"}}
14
+				</a>
15
+			</div>
16
+		</div>
17
+		{{template "repo/commits_table" .}}
17 18
 	</div>
18 19
 </div>
19 20
 {{template "base/footer" .}}

+ 0 - 6
templates/repo/header.tmpl

@@ -73,12 +73,6 @@
73 73
 				</a>
74 74
 			{{end}}
75 75
 
76
-			{{if and (.Repository.UnitEnabled $.UnitTypeCode) (not .IsBareRepo)}}
77
-			<a class="{{if (or (.PageIsCommits) (.PageIsDiff))}}active{{end}} item" href="{{.RepoLink}}/commits/{{EscapePound .BranchName}}">
78
-				<i class="octicon octicon-history"></i> {{.i18n.Tr "repo.commits"}} <span class="ui {{if not .CommitsCount}}gray{{else}}blue{{end}} small label">{{.CommitsCount}}</span>
79
-			</a>
80
-			{{end}}
81
-
82 76
 			{{if and (.Repository.UnitEnabled $.UnitTypeReleases) (not .IsBareRepo) }}
83 77
 			<a class="{{if .PageIsReleaseList}}active{{end}} item" href="{{.RepoLink}}/releases">
84 78
 				<i class="octicon octicon-tag"></i> {{.i18n.Tr "repo.releases"}} <span class="ui {{if not .Repository.NumReleases}}gray{{else}}blue{{end}} small label">{{.Repository.NumReleases}}</span>

+ 1 - 0
templates/repo/home.tmpl

@@ -7,6 +7,7 @@
7 7
 			{{if .Repository.DescriptionHTML}}<span class="description has-emoji">{{.Repository.DescriptionHTML}}</span>{{else if .IsRepositoryAdmin}}<span class="no-description text-italic">{{.i18n.Tr "repo.no_desc"}}</span>{{end}}
8 8
 			<a class="link" href="{{.Repository.Website}}">{{.Repository.Website}}</a>
9 9
 		</p>
10
+		{{template "repo/sub_menu" .}}
10 11
 		<div class="ui secondary menu">
11 12
 			{{if .PullRequestCtx.Allowed}}
12 13
 				<div class="fitted item">

+ 14 - 0
templates/repo/sub_menu.tmpl

@@ -0,0 +1,14 @@
1
+<div class="ui segment sub-menu">
2
+	<div class="ui two horizontal center link list">
3
+		{{if and (.Repository.UnitEnabled $.UnitTypeCode) (not .IsBareRepo)}}
4
+			<div class="item{{if .PageIsCommits}} active{{end}}">
5
+				<a href="{{.RepoLink}}/commits/{{EscapePound .BranchName}}"><i class="octicon octicon-history"></i> <b>{{.CommitsCount}}</b> {{.i18n.Tr "repo.commits"}}</a>
6
+			</div>
7
+		{{end}}
8
+		{{if and (.Repository.UnitEnabled $.UnitTypeCode) (not .IsBareRepo) }}
9
+			<div class="item{{if .PageIsBranches}} active{{end}}">
10
+				<a href="{{.RepoLink}}/branches/"><i class="octicon octicon-git-branch"></i> <b>{{.BrancheCount}}</b> {{.i18n.Tr "repo.branches"}}</a>
11
+			</div>
12
+		{{end}}
13
+	</div>
14
+</div>